Refs and Click Handling
Overview
Refs let you hold a reference to a rendered element so you can interact with it from Go code. The most common use is mouse click handling: attach a ref to a button, then check if a click landed on that button. go-tui provides three ref types (Ref, RefList, and RefMap[K]) for single elements, indexed collections, and keyed collections.
What is a Ref
A Ref is a pointer to a single element in the rendered tree. Create one with tui.NewRef() and attach it to an element using the ref attribute:
type myApp struct {
saveBtn *tui.Ref
}
func MyApp() *myApp {
return &myApp{
saveBtn: tui.NewRef(),
}
}
templ (a *myApp) Render() {
<button ref={a.saveBtn} class="px-2">Save</button>
}
After the first render, a.saveBtn.El() returns the *tui.Element for that button. Before the first render it returns nil, so always check:
if el := a.saveBtn.El(); el != nil {
// safe to use el
}
The generated code calls .Set() on the ref each render cycle, so the ref always points to the current element instance.
Ref Types
go-tui provides three ref types for different use cases:
| Type | Constructor | Use When |
|---|---|---|
*tui.Ref |
tui.NewRef() |
You have a single element to reference |
*tui.RefList |
tui.NewRefList() |
You have elements in a for loop, accessed by index |
*tui.RefMap[K] |
tui.NewRefMap[string]() |
You have elements keyed by a value (string, int, etc.) |
Ref stores one element. RefList stores elements by their loop index; use .At(i) to bind in the template and .El(i) to read back. RefMap[K] stores elements by an arbitrary key; use .At(key) to bind and .El(key) to read back.
Click Handling Pattern
Mouse click handling follows a three-step pattern:
1. Create refs in your constructor:
func MyApp() *myApp {
return &myApp{
saveBtn: tui.NewRef(),
cancelBtn: tui.NewRef(),
}
}
2. Bind refs in your template:
<button ref={a.saveBtn} class="px-2">Save</button>
<button ref={a.cancelBtn} class="px-2">Cancel</button>
3. Wire up HandleMouse with HandleClicks:
func (a *myApp) HandleMouse(me tui.MouseEvent) bool {
return tui.HandleClicks(me,
tui.Click(a.saveBtn, a.onSave),
tui.Click(a.cancelBtn, a.onCancel),
)
}
func (a *myApp) onSave() { /* ... */ }
func (a *myApp) onCancel() { /* ... */ }
HandleClicks checks each Click binding in order. When the mouse event is a left-click press that lands within a ref's element bounds, it calls that binding's handler and returns true. If no binding matches, it returns false.
HandleClicks Details
tui.HandleClicks only responds to left-click press events (MouseLeft with MousePress). It does not fire on release, drag, or right-click. The bindings are checked in order, and the first match wins.
Each tui.Click binding takes a ref (any of the three types) and a func() handler. The framework checks whether the click coordinates fall within the element's rendered bounds. For RefList and RefMap, it checks all stored elements.
The HandleMouse method on your component implements the MouseListener interface. Return true if you handled the event, false to let it propagate.
Refs in Loops
When you use ref= inside a for loop, the generator automatically uses the right ref type based on whether a key attribute is present.
RefList (no key)
Without a key, the ref becomes a RefList, an ordered collection populated with Append on each iteration:
type listApp struct {
items []string
itemRefs *tui.RefList
}
func ListApp() *listApp {
return &listApp{
items: []string{"Alpha", "Beta", "Gamma"},
itemRefs: tui.NewRefList(),
}
}
for _, item := range a.items {
<span ref={a.itemRefs} class="p-1">{item}</span>
}
The generated code calls a.itemRefs.Append(el) for each iteration. Access elements with .At(i) or .All().
RefMap with key
Add a key attribute to turn the ref into a RefMap[K]. Each element is stored under its key:
type tabApp struct {
presetBtns *tui.RefMap[string]
}
for _, p := range presets {
<button ref={a.presetBtns} key={p.name} class="px-1">{p.name}</button>
}
The generated code calls a.presetBtns.Put(p.name, el) for each iteration. Look up elements with .Get(key) or iterate with .All().
This is useful when you need to identify which element was clicked by its key rather than by position.
Combining Keyboard and Mouse
The color mixer example wires both keyboard shortcuts and clickable buttons to the same actions:
func (c *colorMixer) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('r'), func(ke tui.KeyEvent) { c.adjustRed(16) }),
tui.On(tui.Rune('R'), func(ke tui.KeyEvent) { c.adjustRed(-16) }),
tui.On(tui.Rune('g'), func(ke tui.KeyEvent) { c.adjustGreen(16) }),
tui.On(tui.Rune('G'), func(ke tui.KeyEvent) { c.adjustGreen(-16) }),
tui.On(tui.Rune('b'), func(ke tui.KeyEvent) { c.adjustBlue(16) }),
tui.On(tui.Rune('B'), func(ke tui.KeyEvent) { c.adjustBlue(-16) }),
}
}
func (c *colorMixer) HandleMouse(me tui.MouseEvent) bool {
// Check single-ref button clicks
if tui.HandleClicks(me,
tui.Click(c.redUpBtn, func() { c.adjustRed(16) }),
tui.Click(c.redDnBtn, func() { c.adjustRed(-16) }),
tui.Click(c.greenUpBtn, func() { c.adjustGreen(16) }),
tui.Click(c.greenDnBtn, func() { c.adjustGreen(-16) }),
tui.Click(c.blueUpBtn, func() { c.adjustBlue(16) }),
tui.Click(c.blueDnBtn, func() { c.adjustBlue(-16) }),
) {
return true
}
// Check keyed-ref preset button clicks via RefMap
if me.Button == tui.MouseLeft && me.Action == tui.MousePress {
for name, el := range c.presetBtns.All() {
if el != nil && el.ContainsPoint(me.X, me.Y) {
c.applyPreset(name)
return true
}
}
}
return false
}
Both input methods call the same adjust* methods, so the behavior stays consistent. The preset buttons use RefMap.All() to iterate and hit-test by key, letting us identify which preset was clicked by name.
Click Handling in Modals
Modals handle mouse clicks internally. You don't need refs or HandleMouse for buttons inside a modal. Use the onActivate attribute on focusable elements instead:
<modal open={s.showDialog} class="justify-center items-center">
<div class="border-rounded p-2 flex-col gap-1 w-40">
<span class="font-bold">Delete Item?</span>
<div class="flex gap-2 justify-center">
<button class="px-2 border-rounded focusable" onActivate={s.onDelete}>Delete</button>
<button class="px-2 border-rounded focusable" onActivate={s.onCancel}>Cancel</button>
</div>
</div>
</modal>
Clicking a button fires its onActivate callback. Clicking the backdrop (the dimmed area outside the dialog) closes the modal by setting the open state to false.
Enter does the same thing as a click: it fires onActivate on whichever button is focused. Tab and Shift+Tab cycle between focusable elements inside the modal. Escape closes it.
Outside of modals, use refs and HandleClicks for click targets. Inside modals, onActivate is all you need.
Complete Example
This color mixer uses ref-based click handling for the +/- buttons and presets, plus a modal confirmation dialog for the reset action. Each color channel has a visual bar, a value readout, and clickable buttons. Preset colors use RefMap with key:
package main
import (
"fmt"
tui "github.com/grindlemire/go-tui"
)
type colorMixer struct {
red *tui.State[int]
green *tui.State[int]
blue *tui.State[int]
redUpBtn *tui.Ref
redDnBtn *tui.Ref
greenUpBtn *tui.Ref
greenDnBtn *tui.Ref
blueUpBtn *tui.Ref
blueDnBtn *tui.Ref
presetBtns *tui.RefMap[string]
activePreset *tui.State[string]
showReset *tui.State[bool]
}
func ColorMixer() *colorMixer {
return &colorMixer{
red: tui.NewState(128),
green: tui.NewState(64),
blue: tui.NewState(200),
redUpBtn: tui.NewRef(),
redDnBtn: tui.NewRef(),
greenUpBtn: tui.NewRef(),
greenDnBtn: tui.NewRef(),
blueUpBtn: tui.NewRef(),
blueDnBtn: tui.NewRef(),
presetBtns: tui.NewRefMap[string](),
activePreset: tui.NewState(""),
showReset: tui.NewState(false),
}
}
type preset struct {
name string
r, g, b int
}
var presets = []preset{
{"Sunset", 255, 128, 0},
{"Ocean", 0, 100, 255},
{"Forest", 34, 180, 34},
{"Rose", 255, 64, 128},
}
func clamp(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func (c *colorMixer) adjustRed(delta int) {
c.red.Set(clamp(c.red.Get()+delta, 0, 255))
c.activePreset.Set("")
}
func (c *colorMixer) adjustGreen(delta int) {
c.green.Set(clamp(c.green.Get()+delta, 0, 255))
c.activePreset.Set("")
}
func (c *colorMixer) adjustBlue(delta int) {
c.blue.Set(clamp(c.blue.Get()+delta, 0, 255))
c.activePreset.Set("")
}
func (c *colorMixer) confirmReset() {
c.showReset.Set(false)
c.red.Set(128)
c.green.Set(64)
c.blue.Set(200)
c.activePreset.Set("")
}
func (c *colorMixer) cancelReset() {
c.showReset.Set(false)
}
func (c *colorMixer) applyPreset(name string) {
for _, p := range presets {
if p.name == name {
c.red.Set(p.r)
c.green.Set(p.g)
c.blue.Set(p.b)
c.activePreset.Set(name)
return
}
}
}
func (c *colorMixer) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('q'), func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('r'), func(ke tui.KeyEvent) { c.adjustRed(16) }),
tui.On(tui.Rune('R'), func(ke tui.KeyEvent) { c.adjustRed(-16) }),
tui.On(tui.Rune('g'), func(ke tui.KeyEvent) { c.adjustGreen(16) }),
tui.On(tui.Rune('G'), func(ke tui.KeyEvent) { c.adjustGreen(-16) }),
tui.On(tui.Rune('b'), func(ke tui.KeyEvent) { c.adjustBlue(16) }),
tui.On(tui.Rune('B'), func(ke tui.KeyEvent) { c.adjustBlue(-16) }),
tui.On(tui.Rune('x'), func(ke tui.KeyEvent) { c.showReset.Set(true) }),
}
}
func (c *colorMixer) HandleMouse(me tui.MouseEvent) bool {
// Check single-ref button clicks
if tui.HandleClicks(me,
tui.Click(c.redUpBtn, func() { c.adjustRed(16) }),
tui.Click(c.redDnBtn, func() { c.adjustRed(-16) }),
tui.Click(c.greenUpBtn, func() { c.adjustGreen(16) }),
tui.Click(c.greenDnBtn, func() { c.adjustGreen(-16) }),
tui.Click(c.blueUpBtn, func() { c.adjustBlue(16) }),
tui.Click(c.blueDnBtn, func() { c.adjustBlue(-16) }),
) {
return true
}
// Check keyed-ref preset button clicks via RefMap
if me.Button == tui.MouseLeft && me.Action == tui.MousePress {
for name, el := range c.presetBtns.All() {
if el != nil && el.ContainsPoint(me.X, me.Y) {
c.applyPreset(name)
return true
}
}
}
return false
}
func colorBar(value int) string {
filled := value * 20 / 255
bar := ""
for i := 0; i < 20; i++ {
if i < filled {
bar += "█"
} else {
bar += "░"
}
}
return bar
}
templ (c *colorMixer) Render() {
<div class="flex-col p-1 border-rounded border-cyan">
<span class="text-gradient-cyan-magenta font-bold">Color Mixer</span>
// Color preview
<div class="flex-col items-center border-rounded p-1">
<span class="text-gradient-cyan-magenta font-bold">Preview</span>
<div backgroundGradient={tui.NewGradient(tui.Black, tui.RGBColor(uint8(c.red.Get()), uint8(c.green.Get()), uint8(c.blue.Get())))} height={2} width={30}>
<span>{" "}</span>
</div>
<div class="flex gap-2 justify-center">
<span class="text-red font-bold">{fmt.Sprintf("R: %d", c.red.Get())}</span>
<span class="text-green font-bold">{fmt.Sprintf("G: %d", c.green.Get())}</span>
<span class="text-blue font-bold">{fmt.Sprintf("B: %d", c.blue.Get())}</span>
</div>
</div>
// Color bars
<div class="flex-col border-rounded p-1">
<div class="flex gap-1">
<span class="text-red font-bold w-5">Red</span>
<span class="text-red">{colorBar(c.red.Get())}</span>
<span class="text-red font-bold">{fmt.Sprintf("%3d", c.red.Get())}</span>
</div>
<div class="flex gap-1">
<span class="text-green font-bold w-5">Grn</span>
<span class="text-green">{colorBar(c.green.Get())}</span>
<span class="text-green font-bold">{fmt.Sprintf("%3d", c.green.Get())}</span>
</div>
<div class="flex gap-1">
<span class="text-blue font-bold w-5">Blu</span>
<span class="text-blue">{colorBar(c.blue.Get())}</span>
<span class="text-blue font-bold">{fmt.Sprintf("%3d", c.blue.Get())}</span>
</div>
</div>
// Channel controls with refs
<div class="flex gap-1">
<div class="flex-col border-rounded p-1 items-center" flexGrow={1.0}>
<span class="font-bold text-red">Red</span>
<div class="flex gap-1 items-center">
<button ref={c.redDnBtn} class="px-1">{"-"}</button>
<span class="font-bold text-red">{fmt.Sprintf("%3d", c.red.Get())}</span>
<button ref={c.redUpBtn} class="px-1">{"+"}</button>
</div>
</div>
<div class="flex-col border-rounded p-1 items-center" flexGrow={1.0}>
<span class="font-bold text-green">Green</span>
<div class="flex gap-1 items-center">
<button ref={c.greenDnBtn} class="px-1">{"-"}</button>
<span class="font-bold text-green">{fmt.Sprintf("%3d", c.green.Get())}</span>
<button ref={c.greenUpBtn} class="px-1">{"+"}</button>
</div>
</div>
<div class="flex-col border-rounded p-1 items-center" flexGrow={1.0}>
<span class="font-bold text-blue">Blue</span>
<div class="flex gap-1 items-center">
<button ref={c.blueDnBtn} class="px-1">{"-"}</button>
<span class="font-bold text-blue">{fmt.Sprintf("%3d", c.blue.Get())}</span>
<button ref={c.blueUpBtn} class="px-1">{"+"}</button>
</div>
</div>
</div>
// Preset colors using RefMap with key
<div class="flex gap-1 border-rounded p-1 items-center">
<span class="font-bold">Presets:</span>
for _, p := range presets {
if p.name == c.activePreset.Get() {
<button ref={c.presetBtns} key={p.name} class="px-1 font-bold text-cyan">{p.name}</button>
} else {
<button ref={c.presetBtns} key={p.name} class="px-1 font-dim">{p.name}</button>
}
}
</div>
<div class="flex justify-center">
<span class="font-dim">r/g/b increase | R/G/B decrease | x reset | click buttons/presets | q quit</span>
</div>
</div>
// Reset confirmation modal (uses onActivate, not refs)
<modal open={c.showReset} class="justify-center items-center">
<div class="border-rounded p-2 flex-col gap-1 w-40 border-cyan">
<span class="font-bold text-cyan">Reset Colors?</span>
<span>This restores the default RGB values.</span>
<div class="flex gap-2 justify-center">
<button class="px-2 border-rounded focusable" onActivate={c.confirmReset}>Yes</button>
<button class="px-2 border-rounded focusable" onActivate={c.cancelReset}>No</button>
</div>
</div>
</modal>
}
With main.go:
package main
import (
"fmt"
"os"
tui "github.com/grindlemire/go-tui"
)
func main() {
app, err := tui.NewApp(
tui.WithRootComponent(ColorMixer()),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create app: %v\n", err)
os.Exit(1)
}
defer app.Close()
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "App error: %v\n", err)
os.Exit(1)
}
}
Generate and run:
tui generate ./...
go run .
Click the +/- buttons, use r/g/b keys to adjust colors, or click a preset to apply it:

Click "reset" to open the confirmation dialog and click on an option.

Next Steps
- Built-in Elements - Reference guide to every HTML-like element in go-tui
- Streaming Data - Build a live data viewer with channels and auto-scroll