Components
Overview
go-tui has two kinds of components: pure templ functions and struct components.
Pure templ functions are stateless. They take parameters, return elements, and that's it. Use them for reusable visual pieces like cards, badges, and headers.
Struct components carry state. They own State[T] fields, handle keyboard input, run timers, and manage their own lifecycle. Use them for anything interactive or anything that changes over time.
Both kinds compose freely. A struct component's render method can call pure components, and pure components can nest other pure components inside themselves. You build complex UIs by stacking simple pieces.
Pure Components
A pure component is a templ function. It takes parameters and returns a chunk of UI:
package main
import tui "github.com/grindlemire/go-tui"
templ Badge(label string) {
<span class="text-cyan font-bold px-1">{label}</span>
}
templ Header(title string) {
<div class="border-rounded p-1 flex justify-center">
<span class="text-gradient-cyan-magenta font-bold">{title}</span>
</div>
}
templ StatusLine(label string, value string) {
<div class="flex gap-1">
<span class="font-dim">{label}</span>
<span class="text-cyan font-bold">{value}</span>
</div>
}
Call them from other templates just like HTML elements, using @:
templ (a *myApp) Render() {
<div class="flex-col gap-1 p-1">
@Header("Dashboard")
@Badge("v1.0")
@StatusLine("Status:", "Online")
</div>
}
Parameters can be any Go type: strings, ints, booleans, slices, custom structs. The only rule is that pure components don't hold state between renders. Every call constructs a fresh view from the parameters you give it.
Children Slot
Pure components can accept nested content through the {children...} slot. This lets you build wrapper components that add layout, borders, or styling around arbitrary content:
templ Card(title string) {
<div class="border-rounded p-1 flex-col gap-1">
<span class="text-gradient-cyan-magenta font-bold">{title}</span>
<hr class="border-single" />
{children...}
</div>
}
Pass children by nesting elements inside the component call:
templ (a *myApp) Render() {
<div class="flex gap-2">
@Card("System Info") {
@StatusLine("Version:", "1.2.0")
@StatusLine("Uptime:", "3d 14h")
}
@Card("Config") {
@StatusLine("Theme:", "Dark")
@StatusLine("Notify:", "On")
}
</div>
}
Each Card renders its title and divider, then places whatever children you passed where {children...} appears. The children can be elements, other pure components, or any mix.
tui generate compiles the children into a []*tui.Element slice and passes it as a parameter to the generated function. The slot expands to a loop that adds each child element to the parent container.
Struct components also support {children...}. See Children Slot in the struct components section below.
When to Use Pure Components
Use pure components for anything that doesn't need its own state or event handlers:
- Visual elements: cards, badges, headers, dividers
- Layout wrappers: bordered sections, padded containers, centered panels
- Data display: status lines, key-value pairs, formatted labels
- Repeated patterns: list items, table rows, form fields
If you find yourself wanting to add a *tui.State field, you need a struct component instead.
Struct Components
Struct components are Go structs with a templ render method. They hold state, handle input, and manage their own lifecycle:
package main
import (
"fmt"
tui "github.com/grindlemire/go-tui"
)
type counter struct {
count *tui.State[int]
}
func Counter() *counter {
return &counter{
count: tui.NewState(0),
}
}
func (c *counter) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('+'), func(ke tui.KeyEvent) {
c.count.Update(func(v int) int { return v + 1 })
}),
tui.On(tui.Rune('-'), func(ke tui.KeyEvent) {
c.count.Update(func(v int) int { return v - 1 })
}),
}
}
templ (c *counter) Render() {
<div class="flex-col p-1 border-rounded border-cyan items-center gap-1">
<span class="font-bold">Counter</span>
<span class="text-cyan font-bold">{fmt.Sprintf("%d", c.count.Get())}</span>
<span class="font-dim">+/- to change, esc to quit</span>
</div>
}
There are three parts: the struct with state fields, a constructor function that initializes it, and the render method using templ (receiver) Render() syntax.
The render method works exactly like a pure templ function, but it has access to the struct's fields through the receiver. The generated code turns it into a Render(app *App) *Element method, which satisfies the Component interface.
Children Slot
Struct components can also accept children using {children...}. Add a children []*tui.Element field to the struct and accept it in the constructor:
type panel struct {
title string
children []*tui.Element
}
func NewPanel(title string, children []*tui.Element) *panel {
return &panel{title: title, children: children}
}
templ (p *panel) Render() {
<div class="border-rounded p-1 flex-col gap-1">
<span class="font-bold text-gradient-cyan-magenta">{p.title}</span>
{children...}
</div>
}
Callers use the same block syntax as pure components:
templ (a *myApp) Render() {
@NewPanel("Items") {
<span>First item</span>
<span>Second item</span>
}
}
The generated code builds a []*tui.Element slice from the children block and passes it as the last constructor argument. On re-renders, UpdateProps copies the fresh children to the cached instance automatically, so the panel always reflects the latest content.
This is useful when you need a wrapper component that carries its own state (timers, scroll position, internal selections) while still accepting arbitrary content from the parent.
The Component Interface
Every struct component implements this interface:
type Component interface {
Render(app *App) *Element
}
You don't write Render(app *App) *Element by hand. The templ keyword handles the signature. You write templ (c *counter) Render() and tui generate produces the correct Go method.
Component Interfaces
Struct components can implement additional interfaces for input handling, background tasks, and lifecycle hooks. All are optional. Implement only what you need.
KeyListener
Handle keyboard input by implementing KeyMap() tui.KeyMap:
type KeyListener interface {
KeyMap() KeyMap
}
KeyMap() returns a slice of key bindings. The framework calls it on every render pass, so you can return different bindings based on current state:
func (s *search) KeyMap() tui.KeyMap {
if s.active.Get() {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { s.active.Set(false) }),
tui.OnStop(tui.AnyRune,func(ke tui.KeyEvent) {
s.query.Update(func(q string) string { return q + string(ke.Rune) })
}),
}
}
return tui.KeyMap{
tui.On(tui.Rune('/'), func(ke tui.KeyEvent) { s.active.Set(true) }),
}
}
MouseListener
Handle mouse input by implementing HandleMouse(MouseEvent) bool:
type MouseListener interface {
HandleMouse(MouseEvent) bool
}
Return true if you handled the event, false to let it propagate. The typical pattern uses refs and HandleClicks:
func (c *counter) HandleMouse(me tui.MouseEvent) bool {
return tui.HandleClicks(me,
tui.Click(c.incBtn, func() {
c.count.Update(func(v int) int { return v + 1 })
}),
tui.Click(c.decBtn, func() {
c.count.Update(func(v int) int { return v - 1 })
}),
)
}
Mouse events require tui.WithMouse() when creating the app. See the Event Handling guide for details.
WatcherProvider
Run background timers or receive from Go channels by implementing Watchers() []Watcher:
type WatcherProvider interface {
Watchers() []Watcher
}
Watchers start when the component mounts and stop when it unmounts. Callbacks run on the UI thread, so you can safely update state:
func (t *timer) Watchers() []tui.Watcher {
return []tui.Watcher{
tui.OnTimer(time.Second, func() {
t.elapsed.Update(func(v int) int { return v + 1 })
}),
}
}
See Timers, Watchers, and Channels for the full API.
Initializer
Run setup code when a component first mounts by implementing Init() func():
type Initializer interface {
Init() func()
}
Init() is called once when the component enters the tree. The returned function, if non-nil, runs when the component leaves the tree. This pairs setup and cleanup at the same call site:
func (c *conn) Init() func() {
c.ws = connectWebSocket(c.url)
go c.readLoop()
return func() {
c.ws.Close()
}
}
AppBinder
Wire up State and Events fields to the app by implementing BindApp(app *App):
type AppBinder interface {
BindApp(app *App)
}
You rarely write this by hand. The code generator produces BindApp methods automatically for any struct component that has *tui.State or *tui.Events fields. The mount system calls it when the component is first created and again after props updates.
PropsUpdater
Receive updated props when a cached component is re-rendered:
type PropsUpdater interface {
UpdateProps(fresh Component)
}
You never write this by hand. The code generator produces UpdateProps methods automatically for all components. For struct components, it copies non-state fields (skipping *tui.State, *tui.Ref, channels, and functions) from a fresh instance to the cached one. The mount system calls it on subsequent renders so the cached instance picks up any changed props without losing its internal state.
Component Composition
Components compose by nesting. Call a pure component with @, and it inlines its elements into the parent. Call a struct component the same way, and the framework handles mounting and caching behind the scenes.
Pure Components Inside Struct Renders
The most common pattern. A struct component's render method calls pure components for layout and display:
package main
import (
"fmt"
tui "github.com/grindlemire/go-tui"
)
templ Badge(label string, style string) {
<span class={style + " font-bold px-1"}>{label}</span>
}
templ InfoRow(label string, value string) {
<div class="flex gap-1">
<span class="font-dim">{label}</span>
<span class="text-cyan font-bold">{value}</span>
</div>
}
templ Card(title string) {
<div class="border-rounded p-1 flex-col gap-1">
<span class="text-gradient-cyan-magenta font-bold">{title}</span>
<hr class="border-single" />
{children...}
</div>
}
type userProfile struct {
name string
role string
email string
}
func UserProfile(name, role, email string) *userProfile {
return &userProfile{name: name, role: role, email: email}
}
func (u *userProfile) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
}
}
templ (u *userProfile) Render() {
<div class="flex-col gap-1 p-1">
@Card("User Profile") {
<div class="flex-col gap-1">
@InfoRow("Name:", u.name)
@InfoRow("Role:", u.role)
@InfoRow("Email:", u.email)
<div class="flex gap-1">
<span class="font-dim">Status:</span>
@Badge("Active", "text-green")
</div>
</div>
}
</div>
}
Struct Components Inside Struct Renders
When one struct component renders another struct component, the framework uses app.Mount to cache the child instance. This means the child keeps its state across parent re-renders:
package main
import tui "github.com/grindlemire/go-tui"
type sidebar struct {
category *tui.State[string]
selected *tui.State[int]
}
func Sidebar(category *tui.State[string]) *sidebar {
return &sidebar{
category: category,
selected: tui.NewState(0),
}
}
func (s *sidebar) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.Rune('j'), func(ke tui.KeyEvent) {
s.selected.Update(func(v int) int { return v + 1 })
}),
tui.On(tui.Rune('k'), func(ke tui.KeyEvent) {
s.selected.Update(func(v int) int { return v - 1 })
}),
}
}
templ (s *sidebar) Render() {
<div class="flex-col border-single p-1" width={20}>
<span class="font-bold text-cyan">Sidebar</span>
// ... sidebar content ...
</div>
}
type myApp struct {
category *tui.State[string]
}
func MyApp() *myApp {
return &myApp{
category: tui.NewState("Documents"),
}
}
func (a *myApp) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
}
}
templ (a *myApp) Render() {
<div class="flex h-full">
@Sidebar(a.category)
<div class="flex-col flex-1 p-1">
<span>Main content</span>
</div>
</div>
}
When tui generate processes this, it turns @Sidebar(a.category) inside a struct render method into an app.Mount call:
app.Mount(a, 0, func() tui.Component {
return Sidebar(a.category)
})
The mount system caches the sidebar instance by its parent and a cache key (its position in the file, combined with the loop's index or map key when mounted inside a loop). On the first render, it creates the sidebar, binds its state to the app, and calls Init() if implemented. On subsequent renders, it reuses the cached instance and calls UpdateProps if the child implements it.
The sidebar's selected state persists even when the parent re-renders, because the framework reuses the cached instance instead of creating a new one.
Shared State
Parent and child components can share state by passing *tui.State values through constructors. Changes from either side trigger a re-render:
type myApp struct {
category *tui.State[string]
}
func MyApp() *myApp {
return &myApp{
category: tui.NewState("Documents"),
}
}
templ (a *myApp) Render() {
<div class="flex h-full">
@Sidebar(a.category)
@Content(a.category)
</div>
}
Both Sidebar and Content receive the same category state. When the sidebar changes it, the content panel sees the new value on the next render.
Composition Patterns
Layout Wrapper
A pure component that wraps content in a consistent layout:
templ PageLayout(title string) {
<div class="flex-col h-full">
<div class="flex justify-center p-1 border-single">
<span class="text-gradient-cyan-magenta font-bold">{title}</span>
</div>
<div class="flex-col flex-1 p-1">
{children...}
</div>
<div class="flex justify-center p-1 border-single">
<span class="font-dim">Press q to quit</span>
</div>
</div>
}
Data Display
Pure components for showing key-value data:
templ KeyValue(key string, value string) {
<div class="flex gap-1">
<span class="font-dim">{key + ":"}</span>
<span class="text-cyan">{value}</span>
</div>
}
templ Section(title string) {
<div class="flex-col border-rounded p-1 gap-1">
<span class="font-bold">{title}</span>
<hr class="border-single" />
{children...}
</div>
}
Container Component
A reusable scrollable panel:
templ ScrollPanel(title string, height int) {
<div class="flex-col border-rounded" height={height}>
<div class="flex p-1">
<span class="font-bold text-cyan">{title}</span>
</div>
<div class="flex-col overflow-y-scroll flex-1 p-1">
{children...}
</div>
</div>
}
Complete Example
A full app with pure components, a struct component, and composition:
package main
import (
"fmt"
tui "github.com/grindlemire/go-tui"
)
// Pure components
templ Badge(label string, color string) {
<span class={color + " font-bold px-1"}>{label}</span>
}
templ StatusLine(label string, value string) {
<div class="flex gap-1">
<span class="font-dim">{label}</span>
<span class="text-cyan font-bold">{value}</span>
</div>
}
templ Card(title string) {
<div class="border-rounded p-1 flex-col gap-1 w-full" flexGrow={1.0}>
<span class="text-gradient-cyan-magenta font-bold">{title}</span>
<hr class="border-single" />
{children...}
</div>
}
// Tab content components
templ OverviewTab() {
<div class="flex gap-1">
@Card("System") {
@StatusLine("CPU:", "42%")
@StatusLine("Memory:", "1.2 GB")
@StatusLine("Disk:", "68%")
}
@Card("Network") {
@StatusLine("In:", "12 MB/s")
@StatusLine("Out:", "3 MB/s")
@StatusLine("Latency:", "24ms")
}
</div>
}
templ MetricsTab() {
<div class="flex gap-1">
@Card("Performance") {
@StatusLine("Requests:", "1.2k/s")
@StatusLine("P99:", "145ms")
@StatusLine("Errors:", "0.02%")
}
@Card("Storage") {
@StatusLine("Used:", "42 GB")
@StatusLine("Free:", "118 GB")
@StatusLine("IOPS:", "3.4k")
}
</div>
}
templ LogsTab() {
<div class="flex gap-1">
@Card("Application") {
@StatusLine("Level:", "INFO")
@StatusLine("Rate:", "84/min")
@StatusLine("Errors:", "2")
}
@Card("Security") {
@StatusLine("Auth:", "OK")
@StatusLine("Blocked:", "17")
@StatusLine("Alerts:", "0")
}
</div>
}
// Struct component
type dashboard struct {
selected *tui.State[int]
tabs []string
}
func Dashboard() *dashboard {
return &dashboard{
selected: tui.NewState(0),
tabs: []string{"Overview", "Metrics", "Logs"},
}
}
func (d *dashboard) 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) {
d.selected.Update(func(v int) int {
return (v + 1) % len(d.tabs)
})
}),
}
}
templ (d *dashboard) Render() {
<div class="flex-col p-1 gap-1 border-rounded border-cyan">
<div class="flex gap-2">
for i, tab := range d.tabs {
if i == d.selected.Get() {
@Badge(tab, "text-cyan")
} else {
<span class="font-dim">{tab}</span>
}
}
</div>
if d.selected.Get() == 0 {
@OverviewTab()
} else if d.selected.Get() == 1 {
@MetricsTab()
} else {
@LogsTab()
}
<div class="flex justify-center">
<span class="font-dim">{fmt.Sprintf("Tab: switch tabs | esc: quit | Viewing: %s", d.tabs[d.selected.Get()])}</span>
</div>
</div>
}
With main.go:
package main
import (
"fmt"
"os"
tui "github.com/grindlemire/go-tui"
)
func main() {
app, err := tui.NewApp(
tui.WithRootComponent(Dashboard()),
)
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 .
When you run it, you'll get a tabbed dashboard:

Next Steps
- Event Handling -- Keyboard and mouse input in depth
- Timers, Watchers, and Channels -- Background operations and the event bus
- State and Reactivity -- Reactive state management with
State[T]