The cookbook

Eight recipes for the things an app actually needs. Each one is a complete program under docs/recipes/ — not a fragment — so you can run it, change it, and read the parts the listing here cuts away.

Every listing on this page is generated from the program it names. Nothing here is hand-typed HTML, because a sample that only exists as markup drifts from the API — this site has shipped that drift twice, once showing a StateBase method that did not exist. ./docs/build-embeds.sh -check runs in ./scripts/gates.sh and fails the build when a listing goes stale, and the programs themselves are compiled by go build ./....

Each listing is the body of the program, with the package doc, the imports and main cut. Follow the path above a recipe to read the whole thing.

Lay out a scrolling list

docs/recipes/scrolllist/main.go

Past a few dozen rows, build only what is visible. LazyList mounts the rows in the viewport plus a little overscan, measures their heights as they appear, and self-corrects the scrollbar maths as you go — so Count can be ten thousand without the frame cost following it. A ScrollController held in your state gives you the position and programmatic scrolling.

// List is the root widget: immutable configuration, of which there is none.
type List struct{}

func (List) CreateState() widget.State { return &listState{} }

type listState struct {
	widget.StateBase[List]
	rows []string
	// The controller is a plain struct the state owns, not a widget. It has to
	// outlive a rebuild — the list reads its position through it and the app
	// drives it — so it lives here and Build passes its address.
	ctrl     widget.ScrollController
	selected int
}

func (s *listState) Init(widget.Ctx) {
	s.selected = -1
	s.rows = make([]string, 10000)
	for i := range s.rows {
		s.rows[i] = fmt.Sprintf("Row %d", i+1)
	}
}

func (s *listState) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Auto(ctx)

	// LazyList mounts the rows in the viewport plus a little overscan and
	// nothing else, so Count can be large without the frame cost following
	// it. Build is called for one index at a time and must not assume it runs
	// in order, or that it runs at all for a given row.
	list := widget.LazyList{
		Count:           len(s.rows),
		EstimatedExtent: 56, // assumed height of a row not yet measured
		Controller:      &s.ctrl,
		Build: func(i int) widget.Widget {
			// WithKey ties the row's element — and so its state and any
			// animation in flight — to the item rather than to the index.
			// Without it, inserting at the top slides every row's state down
			// by one and the wrong row stays highlighted.
			return widget.WithKey{Key: s.rows[i], Child: theme.ListTile{
				Title:    s.rows[i],
				Subtitle: "built only while visible",
				Selected: i == s.selected,
				OnTap:    func() { s.SetState(func() { s.selected = i }) },
			}}
		},
	}

	col := widget.Column(
		theme.Button{Label: "Back to top", OnTap: func() {
			s.ctrl.AnimateTo(0, 250*time.Millisecond)
		}},
		widget.Sized{H: 12},
		// Expand is the part worth remembering. A scrollable child handed
		// unbounded height tries to be as tall as its content and then never
		// scrolls; inside a Column it must be given the leftover space.
		widget.Expand(list),
	)
	col.CrossAlign = layout.CrossStretch

	return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
		Child: widget.Padding{All: 16, Child: col}}}
}

go run ./docs/recipes/scrolllist

The two mistakes. Forgetting Expand around the list: a scrollable handed unbounded height tries to be as tall as its content and then never scrolls. And forgetting WithKey: without it an insert at the top slides every row's state down by one, and the wrong row stays selected. Use widget.Scroll instead when the content is a fixed, modest amount — a settings page — where windowing buys nothing.

Handle a gesture

docs/recipes/gesture/main.go

widget.Interactive adds no visuals and takes its child's size, so a gesture goes on anything — a chart, a game board, a drawn control. Positions in every callback are local to the widget, so nothing has to walk up the tree subtracting parents' offsets.

// Pad is a drag surface with a dot on it.
type Pad struct{}

func (Pad) CreateState() widget.State { return &padState{} }

type padState struct {
	widget.StateBase[Pad]
	at      geom.Pt // the dot, in the pad's own coordinates
	hovered bool
	last    string // the most recent callback, for the readout
}

func (s *padState) Init(widget.Ctx) { s.at = geom.Pt{X: 120, Y: 90} }

func (s *padState) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Auto(ctx)

	// Positions in every callback are local to the widget, so the dot can be
	// stored exactly as it arrives — no walking up the tree to subtract
	// parents' offsets.
	pad := widget.Interactive{
		Gestures: widget.Gestures{
			// Cursor is a request, not a command: the innermost widget under
			// the pointer wins, and on a platform with no pointer to shape
			// (mobile, the terminal) ctx.Cursor() is nil and nothing happens.
			Cursor: shell.CursorCrosshair,

			OnPress: func(pos geom.Pt) {
				s.SetState(func() { s.at, s.last = pos, "OnPress" })
			},
			// A drag past the tap slop cancels the pending tap, so these two
			// do not both fire for one gesture.
			OnDrag: func(pos, delta geom.Pt) {
				s.SetState(func() { s.at, s.last = pos, "OnDrag" })
			},
			OnTap:       func() { s.SetState(func() { s.last = "OnTap" }) },
			OnDoubleTap: func() { s.SetState(func() { s.last = "OnDoubleTap" }) },
			OnLongPress: func() { s.SetState(func() { s.last = "OnLongPress" }) },

			OnEnter: func() { s.SetState(func() { s.hovered, s.last = true, "OnEnter" }) },
			OnExit:  func() { s.SetState(func() { s.hovered, s.last = false, "OnExit" }) },

			// Setting OnKey (without OnText) makes the widget focusable and
			// claims focus when nothing else holds it, so the arrows work from
			// the first frame with no Autofocus and no tap.
			OnKey: func(k shell.Key) {
				if k.Kind != shell.KeyPress {
					return
				}
				const step = 8
				d, name := geom.Pt{}, ""
				switch k.Code {
				case shell.KeyLeft:
					d, name = geom.Pt{X: -step}, "KeyLeft"
				case shell.KeyRight:
					d, name = geom.Pt{X: step}, "KeyRight"
				case shell.KeyUp:
					d, name = geom.Pt{Y: -step}, "KeyUp"
				case shell.KeyDown:
					d, name = geom.Pt{Y: step}, "KeyDown"
				default:
					return
				}
				s.SetState(func() { s.at, s.last = s.at.Add(d), name })
			},
		},
		Child: widget.Canvas{H: 220, Clip: true, Draw: func(c paint.Canvas, size geom.Size) {
			fill := th.Surface
			if s.hovered {
				fill = th.SurfaceHover
			}
			c.FillRRect(geom.Rect{Max: size.Pt()}, 12, fill)
			c.FillRRect(geom.RectXYWH(s.at.X-9, s.at.Y-9, 18, 18), 9, th.Primary)
		}},
	}

	col := widget.Column(
		theme.Label("Drag, tap, double-tap, hold, or use the arrow keys"),
		widget.Sized{H: 10},
		pad,
		widget.Sized{H: 10},
		widget.Text{
			Value: fmt.Sprintf("last: %s   at: %.0f, %.0f", s.last, s.at.X, s.at.Y),
			Size:  th.Type.Caption,
			Color: th.Muted,
		},
	)
	col.CrossAlign = layout.CrossStretch

	return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
		Child: widget.Padding{All: 16, Child: col}}}
}

go run ./docs/recipes/gesture

What to know. A drag past the tap slop cancels the pending tap, so OnTap and OnDrag do not both fire for one gesture. Setting OnDoubleTap defers that handler's single tap by the double-tap window to disambiguate — which is why it is worth scoping to the one child that needs it rather than putting it on a whole row. A key-only widget (OnKey without OnText) claims focus when nothing else holds it; a text-capturing one never does, because a focused field raises the soft keyboard, so Autofocus is the only way a field starts focused. For nesting a horizontal swipe inside a vertical scroll, see DragAxis, DragPriority and DragClaims in go doc ./widget Gestures — that doc comment is the real reference for this recipe.

Animate a transition

docs/recipes/transition/main.go

Two ways, and choosing between them is the only real decision. Implicit: set a new Value on an Animated wrapper and it tweens there from wherever it currently is — no controller, no ticker, no Dispose. Explicit: hold an *anim.Controller, register it as a ticker, read Value() in Build. Reach for the second only when the motion is not a tween between two states.

type Demo struct{}

func (Demo) CreateState() widget.State { return &demoState{} }

type demoState struct {
	widget.StateBase[Demo]
	ctx  widget.Ctx
	open bool
	// pulse is the explicit side: a controller the state owns, ticked by the
	// runtime, read in Build.
	pulse *anim.Controller
}

func (s *demoState) Init(ctx widget.Ctx) {
	s.ctx = ctx
	s.pulse = &anim.Controller{
		Duration: 900 * time.Millisecond,
		Curve:    anim.EaseInOut,
		// OnChange fires every frame the value moves, and once at the end.
		// SetState(nil) means "nothing to mutate, just repaint".
		OnChange: func() { s.SetState(nil) },
	}
	ctx.AddTicker(s.pulse)
	s.pulse.Forward()
}

// Dispose is not optional for a ticker. An element that goes away without
// removing its ticker leaves the runtime calling into dead state, and the app
// never reaches an idle frame.
func (s *demoState) Dispose() { s.ctx.RemoveTicker(s.pulse) }

func (s *demoState) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Auto(ctx)

	// --- implicit: three tweens driven by one bool -----------------------
	//
	// Each wrapper compares its Value to the one it last saw. Changed, it
	// animates from the current interpolated point — not from the old target —
	// so reversing mid-flight is continuous rather than a jump.
	target, tint := float32(1), th.Surface
	if s.open {
		target, tint = 1.6, th.Primary
	}

	grow := widget.AnimatedScale(target, 220*time.Millisecond,
		swatch(th, "AnimatedScale"))

	recolour := widget.AnimateColor(tint, 220*time.Millisecond, func(c paint.Color) widget.Widget {
		return widget.Decorated{Color: c, Radius: 10,
			Child: widget.Sized{W: 96, H: 56}}
	})

	// A scalar tween is the general case: feed it anything that lerps, here
	// an opacity. Duration 0 means the 150ms default.
	fade := widget.AnimateFloat(boolTo(s.open, 1, 0.25), 0, func(a float32) widget.Widget {
		return widget.Opacity{Alpha: a, Child: swatch(th, "AnimateFloat")}
	})

	// --- explicit: one clock, two properties -----------------------------
	//
	// Value() is in [0,1]. Everything derived from it moves on the same frame,
	// which is the thing two independent implicit tweens cannot promise.
	v := s.pulse.Value()
	pulse := widget.Transform{
		T:      paint.Transform{Rotation: v * 0.5},
		Center: true,
		Child: widget.Decorated{
			Color:  paint.Lerp(th.Surface, th.Warning, v),
			Radius: 10,
			Child:  widget.Sized{W: 96, H: 56},
		},
	}

	row := widget.Row(grow, widget.Sized{W: 14}, recolour, widget.Sized{W: 14}, fade)
	row.CrossAlign = layout.CrossCenter

	col := widget.Column(
		theme.Label("Implicit — one bool, three tweens"),
		widget.Sized{H: 12},
		row,
		widget.Sized{H: 20},
		theme.Button{Label: "Toggle", Primary: true,
			OnTap: func() { s.SetState(func() { s.open = !s.open }) }},
		widget.Sized{H: 24},
		theme.Label("Explicit — one controller, two properties"),
		widget.Sized{H: 12},
		pulse,
		widget.Sized{H: 12},
		theme.Button{Label: "Run again", OnTap: func() {
			// Jump(0) rewinds and repaints; Forward() starts the run.
			s.pulse.Jump(0)
			s.pulse.Forward()
		}},
	)
	col.CrossAlign = layout.CrossCenter

	return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
		Child: widget.Padding{All: 20, Child: widget.Center(col)}}}
}

func swatch(th theme.Theme, label string) widget.Widget {
	return widget.Decorated{
		BorderColor: th.Border, BorderWidth: 1.5, Radius: 10,
		Child: widget.Sized{W: 96, H: 56, Child: widget.Center(
			widget.Text{Value: label, Size: th.Type.Caption, Color: th.Muted})},
	}
}

func boolTo(b bool, yes, no float32) float32 {
	if b {
		return yes
	}
	return no
}

go run ./docs/recipes/transition

What to know. An implicit wrapper reversing mid-flight continues from the current interpolated point, not from the old target, so a toggle flipped twice quickly is smooth rather than jumpy. A ticker you register must be removed in Dispose, or the runtime keeps calling into dead state and the app never reaches an idle frame. anim.Spring is for motion that comes to rest against something; use EaseOut for motion that leaves, because a card being dismissed should not come back a few pixels before it goes.

Honest limitation. There is no velocity handoff. A drag released mid-flight cannot pass its speed into the animation that continues it, so flick-to-dismiss will not carry the gesture's momentum, and that is a real difference in feel from Flutter or Compose. A proper critically-damped solver exists in the scroll implementation but is private to it.

Draw with a custom painter

docs/recipes/custompaint/main.go

For graphics that do not decompose into widgets — a chart, a gauge, a board, generative art — widget.Canvas hands you a paint.Canvas whose origin is the surface's top-left. The same primitives the framework paints with, running through the same rasterizer.

type bar struct {
	label string
	value float32
}

var bars = []bar{
	{"Desktop", 0.82},
	{"Web", 0.64},
	{"iOS", 0.47},
	{"Android", 0.55},
	{"Terminal", 0.21},
}

// Chart is stateless: it has no mutable state, so it implements Build
// directly rather than CreateState. Most custom painters are like this —
// the drawing is a pure function of the data above it.
type Chart struct{}

func (Chart) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Of(ctx)

	return widget.Canvas{H: 220, Clip: true, Draw: func(c paint.Canvas, size geom.Size) {
		const (
			size0 = float32(12) // label size
			gap   = float32(10) // between label and bar
			rowH  = float32(34)
		)

		// Measure every label and lay the chart out around the widest, so the
		// bars all start at the same x and no label is clipped. This is the
		// whole reason measurement belongs on the canvas: the gutter is a
		// property of the text, not a number someone picked.
		gutter := float32(0)
		for _, b := range bars {
			if w := c.MeasureWidthIn("", b.label, size0); w > gutter {
				gutter = w
			}
		}

		// MetricsIn gives the vertical metrics of the face at this size.
		// Centring text on a row means centring the ink between ascent and
		// descent — not placing the baseline at the row's middle, which sits
		// every label noticeably low.
		m := c.MetricsIn("", size0)
		baseline := (rowH + m.Ascent - m.Descent) / 2

		trackX := gutter + gap
		trackW := size.W - trackX
		if trackW < 1 {
			return // too narrow to draw anything honest
		}

		for i, b := range bars {
			top := float32(i) * rowH

			// Right-align the label against the gutter.
			lw := c.MeasureWidthIn("", b.label, size0)
			c.TextIn("", b.label, geom.Pt{X: gutter - lw, Y: top + baseline}, size0, th.Muted)

			track := geom.RectXYWH(trackX, top+9, trackW, 16)
			c.FillRRect(track, 8, th.SurfaceHover)
			c.FillRRect(geom.RectXYWH(trackX, top+9, trackW*b.value, 16), 8, th.ChartAt(i))

			// The value sits just inside the bar's end when it fits there, and
			// just outside when it does not — a decision that needs the width
			// of the text and the width of the bar in the same breath.
			v := fmt.Sprintf("%.0f%%", b.value*100)
			vw := c.MeasureWidthIn("", v, size0)
			x, col := trackX+trackW*b.value-vw-8, th.OnPrimary
			if x < trackX+4 {
				x, col = trackX+trackW*b.value+8, th.Muted
			}
			c.TextIn("", v, geom.Pt{X: x, Y: top + baseline}, size0, col)
		}
	}}
}

type Root struct{}

func (Root) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Auto(ctx)
	col := widget.Column(
		theme.Title("Platform coverage"),
		widget.Sized{H: 6},
		theme.Caption("drawn in one Canvas, laid out from measured text"),
		widget.Sized{H: 16},
		theme.Card{Child: Chart{}},
	)
	col.CrossAlign = layout.CrossStretch
	return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
		Child: widget.Padding{All: 20, Child: col}}}
}

go run ./docs/recipes/custompaint

Measure, do not guess. paint.Canvas embeds paint.Measurer, so the canvas you are given can be asked how wide a run will be before you draw it — and it answers from the same painter that will shape it, so a measurement and the draw that follows cannot disagree. Until recently this was impossible: measurement lived only on the painter, not on the canvas interface, and a custom painter had to assume an average glyph width. Centring text on a row means centring the ink between Ascent and Descent from MetricsIn, not putting the baseline at the row's middle — that sits every label noticeably low.

Other things worth knowing. Drawing may overflow the surface unless you set Clip. Scene diffing compares images, paths and mark batches by identity, so pass the same *Path or image.Image across frames to avoid spurious repaints — and pass a new image value after mutating pixels in place, or the change is not detected. Before drawing a conventional chart by hand, look at the chart package.

Make something accessible

docs/recipes/accessible/main.go

The framework derives semantics from the tree it already has: a Text contributes its string, an Interactive with OnTap contributes a button. That covers most screens. It cannot cover a control painted inside a Canvas, because there is nothing in the tree to read — which is what Sem and Semantics are for.

const stars = 5

type Rating struct{}

func (Rating) CreateState() widget.State { return &ratingState{} }

type ratingState struct {
	widget.StateBase[Rating]
	picked int // 0 = unrated
}

func (s *ratingState) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Auto(ctx)

	kids := make([]widget.Widget, 0, stars)
	for i := 1; i <= stars; i++ {
		n, on := i, i <= s.picked
		kids = append(kids, widget.Interactive{
			// Sem, not a Semantics wrapper around the Interactive. Both work,
			// but Sem keeps the control as one node: a radio nested inside a
			// button is worse for a screen-reader user than either alone,
			// because it has to be stepped through twice.
			//
			// Label is required here. Without it this node is a button with
			// no name — announced as "button", which tells the user nothing.
			// OnActivate defaults to Gestures.OnTap, so the control is
			// operable by a VoiceOver double-tap without saying so twice.
			Sem: &layout.SemInfo{
				Role:    layout.RoleRadio,
				Label:   fmt.Sprintf("%d of %d stars", n, stars),
				Checked: &on,
			},
			Gestures: widget.Gestures{
				OnTap: func() { s.SetState(func() { s.picked = n }) },
			},
			// theme.MinTouchTarget is the platform minimum (44pt). A glyph
			// drawn at 20px is a 20px tap target unless something says
			// otherwise, and that is the single most common accessibility
			// defect in a hand-drawn control.
			Child: widget.Sized{W: theme.MinTouchTarget, H: theme.MinTouchTarget,
				Child: widget.Center(star(th, on))},
		})
	}

	row := widget.Row(kids...)
	row.CrossAlign = layout.CrossCenter

	col := widget.Column(
		// A heading is a landmark: it is how a screen-reader user skims a
		// page instead of reading all of it.
		widget.Semantics{Role: layout.RoleHeading, Label: "Rate this recipe",
			Child: theme.Title("Rate this recipe")},
		widget.Sized{H: 8},

		// Hidden removes a subtree from the semantics tree entirely. A
		// decorative rule carries no information, and announcing it is noise.
		widget.Semantics{Hidden: true, Child: widget.Canvas{H: 6, Draw: func(c paint.Canvas, size geom.Size) {
			c.FillRRect(geom.RectXYWH(0, 2, size.W, 2), 1, th.Border)
		}}},

		widget.Sized{H: 14},
		row,
		widget.Sized{H: 14},

		// Live text is announced when it changes, so the status line is also
		// the confirmation that a tap did something.
		widget.Text{Value: statusLine(s.picked), Size: th.Type.Body, Color: th.Muted},
	)
	col.CrossAlign = layout.CrossStart

	return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
		Child: widget.Padding{All: 20, Child: col}}}
}

func statusLine(n int) string {
	if n == 0 {
		return "Not rated yet"
	}
	return fmt.Sprintf("Rated %d of %d", n, stars)
}

// star is the drawn part. Nothing here contributes semantics — that is the
// whole reason the Interactive above has to declare them.
func star(th theme.Theme, on bool) widget.Widget {
	col := th.Outline
	if on {
		col = th.Primary
	}
	return widget.Canvas{W: 22, H: 22, Draw: func(c paint.Canvas, size geom.Size) {
		c.FillRRect(geom.RectXYWH(3, 3, size.W-6, size.H-6), 3, col)
	}}
}

And the test, which is the point as much as the program is — semantics are an ordinary value under go test, with no screen reader, no emulator and no device:

package main

import (
	"testing"

	"github.com/doug/gophics/apptest"
	"github.com/doug/gophics/layout"
)

// TestSemantics asserts on the accessibility tree the app publishes — the same
// tree the macOS, Windows, Linux, web and Android backends hand to the
// platform. It needs no window, no GPU and no assistive technology installed.
func TestSemantics(t *testing.T) {
	a := apptest.New(t, Rating{}, apptest.WithConfig(config()))

	// Every star is named. A control announced as a bare "radio button" is
	// the defect this test exists to catch.
	for _, label := range []string{
		"1 of 5 stars", "2 of 5 stars", "3 of 5 stars",
		"4 of 5 stars", "5 of 5 stars",
	} {
		a.AssertLabel(label)
	}

	// The role is what decides how the platform announces and operates it.
	if n := a.MustNode("3 of 5 stars"); n.Role != layout.RoleRadio {
		t.Errorf("star role = %v, want RoleRadio", n.Role)
	}

	// The heading is the landmark a screen-reader user navigates by.
	if a.Role(layout.RoleHeading) == nil {
		t.Errorf("no heading in the tree. Labels: %v", a.Labels())
	}

	// Tapping by label is how the test finds the control, rather than by a
	// pixel that moves the moment the layout changes — and it only works
	// because the control is labelled, so this doubles as the check.
	a.TapLabel("4 of 5 stars")
	a.AssertLabel("Rated 4 of 5")

	// Checked has to follow the state, or a screen reader reads the rating
	// the user had before the tap.
	n := a.MustNode("4 of 5 stars")
	if n.Checked == nil || !*n.Checked {
		t.Errorf("4th star Checked = %v, want true", n.Checked)
	}
	if n := a.MustNode("5 of 5 stars"); n.Checked == nil || *n.Checked {
		t.Errorf("5th star Checked = %v, want false", n.Checked)
	}

	// The decorative rule is hidden, so it must not appear as a node. Nothing
	// labels it, which is exactly why a positive assertion is impossible here
	// and the count of nodes is the available check.
	for _, got := range a.Nodes() {
		if got.Role == layout.RoleImage {
			t.Errorf("decorative canvas reached the semantics tree as %v", got)
		}
	}
}

go run ./docs/recipes/accessible  ·  go test ./docs/recipes/accessible

What to know. Prefer Interactive.Sem over wrapping the control in a Semantics: both work, but Sem keeps it as one node, and a checkbox nested inside a button is worse for a screen-reader user than either alone because it has to be stepped through twice. OnActivate defaults to Gestures.OnTap, so you rarely set it. Size drawn controls to theme.MinTouchTarget — a glyph drawn at 20px is a 20px tap target unless something says otherwise, and that is the most common defect in a hand-drawn control. Mark decoration Hidden; announcing a divider is noise.

Accessibility is implemented on macOS (NSAccessibility), Windows (UI Automation), Linux (AT-SPI), the web (a DOM mirror) and Android (AccessibilityNodeProvider), so this is a real check rather than a gesture. The terminal backend has none.

Select and copy text

docs/recipes/selecttext/main.go

Selection is framework-level, not platform-level: gophics paints the highlight, tracks the range and writes to the platform clipboard itself. So double-click-for-a-word, triple-click-for-a-paragraph, shift-click to extend and Cmd/Ctrl+C behave the same on desktop, web, a phone and a terminal, with no DOM text and no native text view involved.

const (
	title = "Selecting text"
	body  = "Selection is the quietest part of an interface and the first " +
		"thing a reader reaches for. Three Text widgets inside one " +
		"SelectionArea select as a single range, the way a browser does."
	attrib = "— the gophics cookbook"
)

type Reader struct{}

func (Reader) CreateState() widget.State { return &readerState{} }

type readerState struct {
	widget.StateBase[Reader]
	selected string
	note     string
}

func (s *readerState) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Auto(ctx)

	passage := widget.Column(
		widget.Text{Value: title, Font: theme.FontBold, Size: th.Type.Title, Color: th.Text, Wrap: true},
		widget.Sized{H: 8},
		widget.Text{Value: body, Size: th.Type.Body, Color: th.Text, Wrap: true},
		widget.Sized{H: 8},
		widget.Text{Value: attrib, Size: th.Type.Caption, Color: th.Muted, Wrap: true},
	)
	passage.CrossAlign = layout.CrossStart

	// The area wraps the padding rather than sitting inside it. An area drawn
	// tightly around the glyphs never sees a press that begins in the margin,
	// so a drag starting just left of the first line would select nothing —
	// where a browser would extend to the nearest line.
	//
	// Text widgets in the subtree opt in by reading the area's registry from
	// context. Nothing about the children changes.
	selectable := widget.SelectionArea{
		Child: widget.Padding{Insets: geom.InsetsAll(14), Child: passage},
		// OnSelect fires on every change, and with "" when the selection is
		// cleared — which is how a Copy button knows to disable itself.
		OnSelect: func(t string) { s.SetState(func() { s.selected = t }) },
	}

	col := widget.Column(
		theme.Caption("Drag across any of it — the three blocks select as one"),
		widget.Sized{H: 10},
		theme.Card{Pad: 0, Child: selectable},
		widget.Sized{H: 16},
		widget.Text{Value: readout(s.selected), Size: th.Type.Caption, Color: th.Muted, Wrap: true},
		widget.Sized{H: 12},
		theme.Button{
			Label:   "Copy selection",
			Primary: s.selected != "",
			OnTap:   func() { s.copy(ctx) },
		},
		widget.Sized{H: 8},
		widget.Text{Value: s.note, Size: th.Type.Caption, Color: th.Muted},
	)
	col.CrossAlign = layout.CrossStretch

	return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
		Child: widget.Padding{All: 18, Child: col}}}
}

// copy writes the selection to the platform clipboard. Cmd/Ctrl+C already does
// this without any code; this exists for the app's own Copy affordance.
//
// ctx.Clipboard() is nil before the runner has provided one, and the write can
// fail — a browser refuses a clipboard write that is not tied to a user
// gesture. Both are ordinary conditions to report, not to panic on.
func (s *readerState) copy(ctx widget.Ctx) {
	if s.selected == "" {
		return
	}
	cb := ctx.Clipboard()
	if cb == nil {
		s.SetState(func() { s.note = "no clipboard on this platform" })
		return
	}
	if err := cb.ClipboardWrite(s.selected); err != nil {
		s.SetState(func() { s.note = "copy refused: " + err.Error() })
		return
	}
	s.SetState(func() { s.note = fmt.Sprintf("copied %d characters", len([]rune(s.selected))) })
}

func readout(sel string) string {
	if sel == "" {
		return "Nothing selected yet."
	}
	n := len([]rune(sel))
	shown := sel
	if r := []rune(shown); len(r) > 64 {
		shown = string(r[:30]) + " … " + string(r[len(r)-30:])
	}
	return fmt.Sprintf("%d characters selected: %q", n, shown)
}

go run ./docs/recipes/selecttext

What to know. Wrap the padding with the SelectionArea, not the other way round: an area drawn tightly around the glyphs never sees a press that begins in the margin, so a drag starting just left of the first line selects nothing where a browser would extend to the nearest line. Text widgets in the subtree opt in by reading the area's registry from context — nothing about the children changes. For a single read-only label, widget.SelectableText is the smaller tool. ctx.Clipboard() is nil before the runner has provided one, and the write can fail: a browser refuses a clipboard write not tied to a user gesture.

Read a platform capability, and handle nil

docs/recipes/capability/main.go

Platform services are plain Go interfaces reached through the build context. ctx.Battery() returns a shell.Battery or nil, and nil means "this platform cannot tell you". That is an answer about the platform, not an error: a build that cannot see controllers must not be confused with a machine that has none, or the app shows a pairing prompt forever. So the right response is almost always to hide the affordance.

type Panel struct{}

func (Panel) CreateState() widget.State { return &panelState{} }

type panelState struct {
	widget.StateBase[Panel]
	subscribed bool
	note       string
}

func (s *panelState) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Auto(ctx)
	s.subscribe(ctx)

	rows := []widget.Widget{
		theme.Title("What this platform can do"),
		widget.Sized{H: 4},
		theme.Caption("Rows that are missing are not broken — the platform does not offer them"),
		widget.Sized{H: 16},
	}

	// Battery: present, so show it. A capability's own methods are the live
	// source; the subscription below is only what tells us to rebuild.
	if b := ctx.Battery(); b != nil {
		state := "on battery"
		if b.Charging() {
			state = "charging"
		}
		rows = append(rows, theme.ListTile{
			Title:    fmt.Sprintf("Battery — %.0f%%", b.Level()*100),
			Subtitle: state,
		})
	} else {
		// Saying so is right for a demo. A real app deletes the row.
		rows = append(rows, theme.ListTile{
			Title:    "Battery",
			Subtitle: "not reported here",
		})
	}

	// Share: the affordance only exists where the platform has a share sheet.
	// There is no fallback worth writing — a hand-rolled share menu that
	// cannot reach the OS targets is worse than no button.
	if sh := ctx.Share(); sh != nil {
		rows = append(rows, theme.ListTile{
			Title:    "Share this recipe",
			Subtitle: "opens the platform share sheet",
			OnTap: func() {
				sh.Share(shell.ShareItem{
					Title: "gophics cookbook",
					Text:  "Reading a platform capability, and handling nil.",
					URL:   "https://github.com/doug/gophics",
				}, func(err error) {
					// Completion, not success: most platforms give no
					// reliable "shared" signal, and a dismissal arrives here
					// with a nil error.
					s.SetState(func() {
						if err != nil {
							s.note = "share failed: " + err.Error()
							return
						}
						s.note = "share sheet closed"
					})
				})
			},
		})
	}

	// Haptics: the common case for a capability that *enriches* an action
	// rather than being the action. The tap must still work without it, so the
	// nil check guards one line and not the handler.
	rows = append(rows, theme.ListTile{
		Title:    "Tap me",
		Subtitle: hapticNote(ctx),
		OnTap: func() {
			if h := ctx.Haptic(); h != nil {
				h.Play(shell.HapticSelection)
			}
			s.SetState(func() { s.note = "tapped" })
		},
	})

	if s.note != "" {
		rows = append(rows,
			widget.Sized{H: 12},
			widget.Text{Value: s.note, Size: th.Type.Caption, Color: th.Muted})
	}

	col := widget.Column(rows...)
	col.CrossAlign = layout.CrossStretch

	return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
		Child: widget.Padding{All: 18, Child: col}}}
}

// subscribe registers for change notifications once.
//
// Capabilities are nil at mount — the tree builds before the shell is wired —
// and arrive on the first frame, with a rebuild when they do. So this belongs
// in Build and not in Init, guarded to run exactly once. Getting that wrong is
// the standard way an app concludes a capability is missing when it is not.
func (s *panelState) subscribe(ctx widget.Ctx) {
	if s.subscribed {
		return
	}
	b := ctx.Battery()
	if b == nil {
		return // either not wired yet, or genuinely absent — retry next frame
	}
	s.subscribed = true
	// OnChange takes no argument: it says something moved, and the app reads
	// Level() and Charging() again on the next build.
	b.OnChange(func() { s.SetState(nil) })
}

func hapticNote(ctx widget.Ctx) string {
	if ctx.Haptic() == nil {
		return "no haptics here — the tap still works"
	}
	return "with a selection tick"
}

go run ./docs/recipes/capability  ·  or see examples/capabilities for every capability at once

The timing trap. Capabilities are nil at mount — the tree builds before the shell is wired — and arrive on the first frame, with a rebuild when they do. So a subscription belongs in Build, guarded to run once, and not in Init. Checking in Init is the standard way an app concludes a capability is missing when it is not.

The framework will not publish a capability whose implementation always returns nothing, for the same reason: desktop connectivity used to report online = true unconditionally and is now nil instead, which is the honest answer. The per-platform matrix lives in internal/capgen/README.md and is generated from the source, so it cannot claim support that is not there.

Theme an app

docs/recipes/themed/main.go

A theme is a plain struct of design tokens — colours, a corner radius, a type scale. No registry, no inheritance, no cascade: the nearest Provide above a widget wins, which is the one rule. Components call theme.Of(ctx) to find it.

// brand is the whole customisation: start from a built-in theme and move the
// tokens that are actually yours.
func brand(dark bool) theme.Theme {
	t := theme.Light()
	if dark {
		t = theme.Dark()
	}
	t.Primary = paint.RGB(0.25, 0.47, 0.84)
	t.OnPrimary = paint.RGB(1, 1, 1)
	t.Radius = 14
	t.Type.Body = 15
	t.Type.Heading = 19
	return t
}

type App struct{}

func (App) CreateState() widget.State { return &appState{} }

type appState struct {
	widget.StateBase[App]
	// override is nil to follow the platform, or a pinned choice. An app with
	// a theme setting needs all three states — light, dark, follow — and a
	// bare bool cannot express the third.
	override *bool
}

func (s *appState) Build(ctx widget.Ctx) widget.Widget {
	// ctx.DarkMode() is the platform's current colour scheme, and it changes
	// while the app runs: the subtree under this Provide rebuilds when it
	// does. That is why a derived value like a theme belongs in the tree and
	// not in app.Config.Provide, which is for things built once at startup.
	dark := ctx.DarkMode()
	if s.override != nil {
		dark = *s.override
	}
	th := brand(dark)

	label := "Following the system"
	switch {
	case s.override != nil && *s.override:
		label = "Pinned dark"
	case s.override != nil:
		label = "Pinned light"
	}

	col := widget.Column(
		theme.Heading("Themed components"),
		widget.Sized{H: 4},
		theme.Caption("every colour, radius and text size below comes from the theme"),
		widget.Sized{H: 16},

		// Nothing here names a colour. theme.Of(ctx) inside each component
		// finds the Provide below, so re-theming the app is one struct change
		// rather than a sweep through the tree.
		theme.Card{Child: widget.Column(
			theme.Button{Label: "Primary action", Primary: true, OnTap: func() {}},
			widget.Sized{H: 10},
			theme.Button{Label: "Secondary", OnTap: func() {}},
			widget.Sized{H: 10},
			theme.Checkbox{Label: "Outline is a different token from Border", Checked: true},
			widget.Sized{H: 10},
			theme.Field{Placeholder: "A themed field"},
		)},

		widget.Sized{H: 18},
		theme.Segmented{
			Options:  []string{"Light", "Dark", "System"},
			Selected: selectedIndex(s.override),
			OnChange: func(i int) {
				s.SetState(func() {
					switch i {
					case 0:
						s.override = boolPtr(false)
					case 1:
						s.override = boolPtr(true)
					default:
						s.override = nil
					}
				})
			},
		},
		widget.Sized{H: 8},
		theme.Caption(label),
	)
	col.CrossAlign = layout.CrossStretch

	// Provide sits above everything that reads the theme, including the Fill
	// that paints the background. Put it below the Fill and the page keeps the
	// old background for one frame after a scheme change.
	return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
		Child: widget.Padding{All: 20, Child: col}}}
}

func selectedIndex(override *bool) int {
	switch {
	case override == nil:
		return 2
	case *override:
		return 1
	default:
		return 0
	}
}

func boolPtr(b bool) *bool { return &b }

go run ./docs/recipes/themed

Derive, do not construct. theme.Theme has around twenty colour fields, and the two that catch people out are Border (decorative chrome — dividers, a panel's hairline, allowed to be very faint) and Outline (the edge of an interactive control, which must stay legible because it is the only thing telling the user the control exists). A theme written field by field for pretty panel edges erases its own checkboxes; the built-in glass presets did exactly that before the two tokens were separated.

Where the theme goes. In the tree, as a Provide — not in app.Config.Provide. Config.Provide is for app-lifetime values built once at startup: an API client, a database handle, a clock. A theme is derived from the platform's colour scheme, which changes while the app runs, and the subtree below a Provide rebuilds when its value does. Set Config.Background and Config.BackgroundDark together, too: the window background is resolved per frame from the same signal the theme is, so a single fixed colour there renders dark-theme text over a light page the moment the viewer prefers dark — and nothing errors.

Recipes that are not here, and why

The learning path →  ·  The API tour  ·  Coming from Flutter or Compose