Focus Management
Overview
Focus determines which element receives keyboard input. go-tui handles element-level focus automatically. Mark elements as focusable and use app.FocusNext() / app.FocusPrev() to cycle between them. For section-level switching (e.g., sidebar vs content panel), FocusGroup manages mutual-exclusion state using *State[bool] values with built-in Tab/Shift+Tab bindings.
Making Elements Focusable
Mark an element as focusable with the focusable attribute in your .gsx template:
<button focusable={true} class="px-2 border-single">Click me</button>
You can also attach focus and blur callbacks. These fire when the element gains or loses focus:
<div
focusable={true}
onFocus={func(el *tui.Element) { el.SetBorderStyle(tui.NewStyle().Foreground(tui.ANSIColor(tui.Cyan))) }}
onBlur={func(el *tui.Element) { el.SetBorderStyle(tui.NewStyle()) }}
class="border-single p-1"
>
<span>Focus me</span>
</div>
Setting onFocus or onBlur implicitly makes the element focusable, so you don't need to also set focusable={true}.
In Go code, the same behavior is available through option functions:
elem := tui.New(
tui.WithFocusable(true),
tui.WithOnFocus(func(el *tui.Element) {
el.SetBorderStyle(tui.NewStyle().Foreground(tui.ANSIColor(tui.Cyan)))
}),
tui.WithOnBlur(func(el *tui.Element) {
el.SetBorderStyle(tui.NewStyle())
}),
)
To check whether an element has focus, call IsFocused():
if s.myRef.El() != nil && s.myRef.El().IsFocused() {
<span class="text-cyan">Focused</span>
} else {
<span class="text-dim">Not focused</span>
}
Focus Navigation
The App provides three methods for focus control:
app.FocusNext()-- move focus to the next focusable elementapp.FocusPrev()-- move focus to the previous focusable elementapp.Focused()-- get the currently focusedFocusable, or nil
Focus order follows document order (depth-first traversal of the element tree). FocusNext wraps from the last element back to the first; FocusPrev wraps from the first to the last.
Wire these up in your KeyMap to give users Tab/Shift+Tab navigation:
func (f *myForm) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.KeyTab, func(ke tui.KeyEvent) { ke.App().FocusNext() }),
tui.On(tui.KeyTab.Shift(), func(ke tui.KeyEvent) { ke.App().FocusPrev() }),
}
}
When the app starts, the first focusable element in the tree is automatically focused. If no elements are focusable, nothing receives focus.
FocusGroup
FocusGroup manages Tab/Shift+Tab cycling between logical sections of your UI. Instead of tracking individual elements, it works with *State[bool] values. Each member state is true when that section is active and false otherwise. The group maintains mutual exclusion: exactly one member is active at a time.
Create a focus group with two or more *State[bool] members:
type myForm struct {
sidebarActive *tui.State[bool]
contentActive *tui.State[bool]
footerActive *tui.State[bool]
focus *tui.FocusGroup
}
func MyForm() *myForm {
sidebar := tui.NewState(true) // starts active
content := tui.NewState(false)
footer := tui.NewState(false)
return &myForm{
sidebarActive: sidebar,
contentActive: content,
footerActive: footer,
focus: tui.MustNewFocusGroup(sidebar, content, footer),
}
}
NewFocusGroup sets the first member to true and all others to false. It returns an error if you pass fewer than 2 members. MustNewFocusGroup panics on error, which is fine for constructors where the member count is known at compile time.
Navigation
FocusGroup has three methods:
fg.Next()-- deactivate the current member, activate the next (wraps around)fg.Prev()-- deactivate the current member, activate the previous (wraps around)fg.Current()-- return the index of the currently active member
Built-in KeyMap
FocusGroup implements the KeyListener interface. Its KeyMap() returns bindings for Tab (calls Next) and Shift+Tab (calls Prev). Include it in your component's KeyMap by spreading the group's bindings:
func (f *myForm) KeyMap() tui.KeyMap {
return append(f.focus.KeyMap(), []tui.KeyBinding{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
// section-specific bindings...
}...)
}
Using Active State in Render
Read each member state in your template to change styles based on which section is active:
templ (f *myForm) Render() {
<div class="flex gap-1 h-full">
if f.sidebarActive.Get() {
<div class="flex-col border-rounded border-cyan p-1" width={20}>
<span class="text-cyan font-bold">Sidebar</span>
<span>Use Tab to switch</span>
</div>
} else {
<div class="flex-col border-rounded border-black p-1" width={20}>
<span class="font-dim">Sidebar</span>
</div>
}
if f.contentActive.Get() {
<div class="flex-col grow border-rounded border-cyan p-1">
<span class="text-cyan font-bold">Content</span>
<span>This panel is active</span>
</div>
} else {
<div class="flex-col grow border-rounded border-black p-1">
<span class="font-dim">Content</span>
</div>
}
if f.footerActive.Get() {
<div class="flex-col border-rounded border-cyan p-1" width={20}>
<span class="text-cyan font-bold">Footer</span>
</div>
} else {
<div class="flex-col border-rounded border-black p-1" width={20}>
<span class="font-dim">Footer</span>
</div>
}
</div>
}
Programmatic Focus
Elements provide direct focus control through these methods:
element.Focus() // give this element focus
element.Blur() // remove focus from this element
element.IsFocused() bool // check if this element has focus
element.IsFocusable() bool // check if this element can receive focus
element.SetFocusable(bool) // enable or disable focusability at runtime
Focus() and Blur() trigger the onFocus and onBlur callbacks if they were set via WithOnFocus/WithOnBlur or SetOnFocus/SetOnBlur.
You can also set callbacks programmatically after creation:
element.SetOnFocus(func(el *tui.Element) {
// called when element gains focus
})
element.SetOnBlur(func(el *tui.Element) {
// called when element loses focus
})
Both SetOnFocus and SetOnBlur implicitly set the element as focusable.
Modal Focus Trapping
When a <modal> has trapFocus enabled (the default), Tab and Shift+Tab only cycle through focusable elements inside the modal. Elements outside the modal are unreachable until it closes. A catch-all binding also blocks unhandled keys from parent handlers.
With trapFocus={false}, Tab and other keys propagate to parent components normally. You can combine this with the keyMap attribute to add custom hotkeys while letting everything else pass through.
The modal handles Enter by triggering the focused element's onActivate callback regardless of the trapFocus setting. When the modal closes, focus returns to the previously focused element.
Focusable elements with borders receive an automatic cyan border highlight when focused. You can override this behavior by providing your own onFocus and onBlur handlers.
Complete Example
This form has three panels that highlight when active. Tab and Shift+Tab cycle between them using FocusGroup:
package main
import (
"fmt"
tui "github.com/grindlemire/go-tui"
)
type panelForm struct {
panels []string
panel1 *tui.State[bool]
panel2 *tui.State[bool]
panel3 *tui.State[bool]
focus *tui.FocusGroup
clickCount *tui.State[int]
}
func PanelForm() *panelForm {
p1 := tui.NewState(true)
p2 := tui.NewState(false)
p3 := tui.NewState(false)
return &panelForm{
panels: []string{"Inbox", "Drafts", "Sent"},
panel1: p1,
panel2: p2,
panel3: p3,
focus: tui.MustNewFocusGroup(p1, p2, p3),
clickCount: tui.NewState(0),
}
}
func (p *panelForm) KeyMap() tui.KeyMap {
return append(p.focus.KeyMap(), []tui.KeyBinding{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune(' '), func(ke tui.KeyEvent) {
p.clickCount.Update(func(v int) int { return v + 1 })
}),
}...)
}
templ (p *panelForm) Render() {
<div class="flex-col gap-1 p-1">
<span class="font-bold text-gradient-cyan-magenta">Focus Demo — Tab to switch, Space to interact</span>
<div class="flex gap-1">
for i, name := range p.panels {
if i == p.focus.Current() {
<div class="flex-col border-rounded border-cyan p-1" width={20}>
<span class="text-cyan font-bold">{name}</span>
<span class="text-bright-white">{fmt.Sprintf("Actions: %d", p.clickCount.Get())}</span>
</div>
} else {
<div class="flex-col border-rounded border-black p-1" width={20}>
<span class="font-dim">{name}</span>
</div>
}
}
</div>
<span class="text-dim">Press Esc to quit</span>
</div>
}
With main.go:
package main
import (
"fmt"
"os"
tui "github.com/grindlemire/go-tui"
)
func main() {
app, err := tui.NewApp(
tui.WithRootComponent(PanelForm()),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer app.Close()
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
Generate and run:
tui generate ./...
go run .
Tab between the panels to see focus in action:

Next Steps
- Building a Dashboard -- Build a live metrics dashboard from scratch
- Events Guide -- Keyboard and mouse event handling