bubbletea-docs
Original:🇺🇸 English
Translated
Bubble Tea is a Go framework for building elegant terminal user interfaces (TUIs). Use when building CLI applications with interactive menus, forms, lists, tables, or any terminal UI. Based on The Elm Architecture (Model-View-Update). Key features: keyboard/mouse input, responsive layouts, async commands, Bubbles components (spinner, list, table, viewport, textinput, progress). Includes Lip Gloss for styling and Huh for forms.
2installs
Sourcequantmind-br/skills
Added on
NPX Install
npx skill4agent add quantmind-br/skills bubbletea-docsTags
Translated version includes tags in frontmatterSKILL.md Content
View Translation Comparison →Bubble Tea TUI Framework Skill
Overview
Bubble Tea is a powerful TUI framework for Go based on The Elm Architecture. Every program has a Model (state) and three methods: (initial command), (handle messages), (render UI as string).
Init()Update()View()This skill covers the complete Charm ecosystem:
- Bubble Tea - Core TUI framework
- Bubbles - Reusable UI components
- Lip Gloss - Terminal styling
- Huh - Interactive forms
Installation
bash
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/bubbles # UI components
go get github.com/charmbracelet/lipgloss # Styling
go get github.com/charmbracelet/huh # FormsQuick Start
go
package main
import (
"fmt"
"log"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
cursor int
choices []string
selected map[int]struct{}
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 { m.cursor-- }
case "down", "j":
if m.cursor < len(m.choices)-1 { m.cursor++ }
case "enter", " ":
if _, ok := m.selected[m.cursor]; ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() string {
s := "Choose:\n\n"
for i, choice := range m.choices {
cursor, checked := " ", " "
if m.cursor == i { cursor = ">" }
if _, ok := m.selected[i]; ok { checked = "x" }
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
return s + "\nq to quit.\n"
}
func main() {
p := tea.NewProgram(model{
choices: []string{"Option A", "Option B", "Option C"},
selected: make(map[int]struct{}),
})
if _, err := p.Run(); err != nil { log.Fatal(err) }
}Core Concepts
Messages and Commands
go
// Custom messages
type tickMsg time.Time
type dataMsg struct{ data string }
type errMsg struct{ error }
// Command returns a message (runs async)
func fetchData() tea.Msg {
resp, err := http.Get("https://api.example.com")
if err != nil { return errMsg{err} }
defer resp.Body.Close()
// ... process response
return dataMsg{data: "result"}
}
// Handle in Update
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case dataMsg:
m.data = msg.data
case errMsg:
m.err = msg.error
}
return m, nil
}
// Start command in Init
func (m model) Init() tea.Cmd {
return fetchData // Runs async, sends message when done
}Batch and Sequence Commands
go
// Parallel execution
return tea.Batch(cmd1, cmd2, cmd3)
// Sequential execution
return tea.Sequence(step1, step2, tea.Quit)Window Size Handling
go
type model struct {
width, height int
viewport viewport.Model
ready bool
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height-4)
m.viewport.SetContent(m.content)
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 4
}
}
return m, nil
}Common Patterns
Program Options
go
// Fullscreen
p := tea.NewProgram(m, tea.WithAltScreen())
// Mouse support
p := tea.NewProgram(m, tea.WithMouseCellMotion())
// Combined
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())Key Bindings with bubbles/key
go
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Quit key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}
// In Update
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Up):
m.cursor--
case key.Matches(msg, keys.Quit):
return m, tea.Quit
}Multiple Views
go
type model struct {
state int // 0=menu, 1=detail
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.state {
case 0: return m.updateMenu(msg)
case 1: return m.updateDetail(msg)
}
return m, nil
}
func (m model) View() string {
switch m.state {
case 0: return m.menuView()
case 1: return m.detailView()
}
return ""
}Bubbles Components
Component Quick Reference
| Component | Import | Init | Key Method |
|---|---|---|---|
| Spinner | | | |
| TextInput | | | |
| TextArea | | | |
| List | | | |
| Table | | | |
| Viewport | | | |
| Progress | | | |
| Help | | | |
| FilePicker | | | |
| Timer | | | |
| Stopwatch | | | |
Focus Management (Multiple Inputs)
go
type model struct {
inputs []textinput.Model
focusIndex int
}
func (m *model) nextInput() tea.Cmd {
m.inputs[m.focusIndex].Blur()
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
return m.inputs[m.focusIndex].Focus()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab":
return m, m.nextInput()
}
}
// Update all inputs
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
return m, tea.Batch(cmds...)
}Component Composition
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
// Update all components and collect commands
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
m.textInput, cmd = m.textInput.Update(msg)
cmds = append(cmds, cmd)
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}Spinner Example
go
s := spinner.New()
s.Spinner = spinner.Dot // Line, Dot, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
// In Init(): return m.spinner.Tick
// In Update(): handle spinner.TickMsgList with Custom Items
go
type item struct{ title, desc string }
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title } // Required for filtering
items := []list.Item{item{"One", "Description"}}
l := list.New(items, list.NewDefaultDelegate(), 30, 10)
l.Title = "Select Item"Table
go
columns := []table.Column{
{Title: "ID", Width: 10},
{Title: "Name", Width: 20},
}
rows := []table.Row{
{"1", "Alice"},
{"2", "Bob"},
}
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(7),
)Lip Gloss Styling
Basic Styling
go
import "github.com/charmbracelet/lipgloss"
var style = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
Background(lipgloss.Color("#7D56F4")).
Border(lipgloss.RoundedBorder()).
Padding(1, 2)
output := style.Render("Hello, World!")Colors
go
// Hex colors
lipgloss.Color("#FF00FF")
// ANSI 256 colors
lipgloss.Color("205")
// Adaptive colors (auto light/dark background)
lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}
// Complete color profiles
lipgloss.CompleteColor{
TrueColor: "#FF00FF",
ANSI256: "205",
ANSI: "5",
}Layout Composition
go
// Horizontal layout
left := lipgloss.NewStyle().Width(20).Render("Left")
right := lipgloss.NewStyle().Width(20).Render("Right")
row := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
// Vertical layout
header := "Header"
body := "Body content"
footer := "Footer"
page := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
// Center content in box
centered := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content)Frame Size for Calculations
go
// Account for padding/border/margin in calculations
h, v := docStyle.GetFrameSize()
m.list.SetSize(m.width-h, m.height-v)
// Dynamic content height
headerH := lipgloss.Height(m.header())
footerH := lipgloss.Height(m.footer())
m.viewport.Height = m.height - headerH - footerHBorder Styles
go
lipgloss.NormalBorder() // Standard box
lipgloss.RoundedBorder() // Rounded corners
lipgloss.ThickBorder() // Thick lines
lipgloss.DoubleBorder() // Double lines
lipgloss.HiddenBorder() // Invisible (for spacing)Huh Forms
Quick Start
go
import "github.com/charmbracelet/huh"
var name string
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("What's your name?").
Value(&name),
huh.NewConfirm().
Title("Ready to proceed?").
Value(&confirmed),
),
)
err := form.Run()
if err != nil {
if err == huh.ErrUserAborted {
fmt.Println("Cancelled")
return
}
log.Fatal(err)
}Field Types (GENERIC TYPES REQUIRED)
go
// Input - single line text
huh.NewInput().Title("Name").Value(&name)
// Text - multi-line
huh.NewText().Title("Bio").Lines(5).Value(&bio)
// Select - MUST specify type
huh.NewSelect[string]().
Title("Choose").
Options(
huh.NewOption("Option A", "a"),
huh.NewOption("Option B", "b"),
).
Value(&choice)
// MultiSelect - MUST specify type
huh.NewMultiSelect[string]().
Title("Choose many").
Options(huh.NewOptions("A", "B", "C")...).
Limit(2).
Value(&choices)
// Confirm
huh.NewConfirm().
Title("Sure?").
Affirmative("Yes").
Negative("No").
Value(&confirmed)
// FilePicker
huh.NewFilePicker().
Title("Select file").
AllowedTypes([]string{".go", ".md"}).
Value(&filepath)Validation
go
huh.NewInput().
Title("Email").
Value(&email).
Validate(func(s string) error {
if !strings.Contains(s, "@") {
return errors.New("invalid email")
}
return nil
})Dynamic Forms (OptionsFunc/TitleFunc)
go
var country string
var state string
huh.NewSelect[string]().
Value(&state).
TitleFunc(func() string {
if country == "Canada" { return "Province" }
return "State"
}, &country). // Recompute when country changes
OptionsFunc(func() []huh.Option[string] {
return huh.NewOptions(getStatesFor(country)...)
}, &country)Bubble Tea Integration
go
type Model struct {
form *huh.Form
}
func (m Model) Init() tea.Cmd {
return m.form.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.form.State == huh.StateCompleted {
// Form completed - access values
name := m.form.GetString("name")
return m, tea.Quit
}
return m, cmd
}
func (m Model) View() string {
if m.form.State == huh.StateCompleted {
return "Done!"
}
return m.form.View()
}Themes
go
form.WithTheme(huh.ThemeCharm()) // Default pink/purple
form.WithTheme(huh.ThemeDracula()) // Dark purple/pink
form.WithTheme(huh.ThemeCatppuccin()) // Pastel
form.WithTheme(huh.ThemeBase16()) // Muted
form.WithTheme(huh.ThemeBase()) // MinimalSpinner for Loading
go
import "github.com/charmbracelet/huh/spinner"
err := spinner.New().
Title("Processing...").
Action(func() {
time.Sleep(2 * time.Second)
processData()
}).
Run()Common Gotchas
Bubble Tea Core
-
Blocking in Update/View: Never block. Use commands for I/O:go
// BAD time.Sleep(time.Second) // Blocks event loop! // GOOD return m, func() tea.Msg { time.Sleep(time.Second) return doneMsg{} } -
Goroutines modifying model: Race condition! Use commands instead:go
// BAD go func() { m.data = fetch() }() // Race! // GOOD return m, func() tea.Msg { return dataMsg{fetch()} } -
Viewport before WindowSizeMsg: Initialize after receiving dimensions:go
if !m.ready { m.viewport = viewport.New(msg.Width, msg.Height) m.ready = true } -
Using receiver methods incorrectly: Only usefor internal helpers. Model interface methods must use value receivers
func (m *model).func (m model) -
Startup commands via Init: Don't usein Init. Use
tea.EnterAltScreenoption instead.tea.WithAltScreen() -
Messages not in order:results arrive in ANY order. Use
tea.Batchwhen order matters.tea.Sequence
Bubbles Components
-
Spinner not animating: Must returnfrom
m.spinner.Tickand handleInit()in Update.spinner.TickMsg -
TextInput not accepting input: Must callbefore input accepts keystrokes.
.Focus() -
Component updates not reflected: Always reassign after Update:go
m.spinner, cmd = m.spinner.Update(msg) // Must reassign! -
Multiple components losing commands: Useto combine all commands.
tea.Batch(cmds...) -
List items not filtering: Must implementon items.
FilterValue() string -
Table not responding: Must setor call
table.WithFocused(true).t.Focus()
Lip Gloss
-
Style method has no effect: Styles are immutable, must reassign:go
// BAD style.Bold(true) // Result discarded! // GOOD style = style.Bold(true) -
Alignment not working: Requires explicit Width:go
style := lipgloss.NewStyle().Width(40).Align(lipgloss.Center) -
Width calculation wrong: Usenot
lipgloss.Width()for unicode.len() -
Layout arithmetic errors: Useto account for padding/border/margin.
style.GetFrameSize()
Huh Forms
-
Generic types required:and
SelectMUST have type parameter:MultiSelectgo// BAD - won't compile huh.NewSelect() // GOOD huh.NewSelect[string]() -
Value takes pointer: Always pass pointer:not
.Value(&myVar)..Value(myVar) -
OptionsFunc not updating: Must pass binding variable:go
.OptionsFunc(fn, &country) // Recomputes when country changes -
Don't use Placeholder() in huh forms: Causes rendering bugs. Put examples in Description instead:go
// BAD huh.NewInput().Placeholder("example@email.com") // GOOD huh.NewInput().Description("e.g. example@email.com") -
Loop variable closure capture: Capture explicitly in loops:go
for _, name := range items { currentName := name // Capture! huh.NewInput().Value(&configs[currentName].Field) } -
Don't intercept Enter before form: Let huh handle Enter for navigation.
Debugging
go
// Log to file (stdout is the TUI)
if os.Getenv("DEBUG") != "" {
f, _ := tea.LogToFile("debug.log", "app")
defer f.Close()
}
log.Println("Debug message")Best Practices
- Keep Update/View fast - The event loop blocks on these
- Use tea.Cmd for all I/O - HTTP, file, database operations
- Use tea.Batch for parallel - Multiple independent commands
- Use tea.Sequence for ordered - Commands that must run in order
- Store window dimensions - Handle tea.WindowSizeMsg, update components
- Initialize viewport after WindowSizeMsg - Dimensions aren't available at start
- Use value receivers - not
func (m model) Updatefunc (m *model) Update - Define styles as package variables - Reuse instead of creating in loops
- Use AdaptiveColor - For light/dark terminal support
- Handle ErrUserAborted - Graceful Ctrl+C handling in huh forms
Additional Resources
- references/API.md - Complete API reference
- references/EXAMPLES.md - Extended code examples
- references/TROUBLESHOOTING.md - Common errors and solutions
Essential Imports
go
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table" // Static tables
"github.com/charmbracelet/lipgloss/tree" // Hierarchical data
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/table" // Interactive tables
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/filepicker"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/timer"
"github.com/charmbracelet/bubbles/stopwatch"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/wish" // SSH TUI server
"github.com/lrstanley/bubblezone" // Mouse zones
)Lip Gloss Tree (Hierarchical Data)
Render tree structures with customizable enumerators:
go
import "github.com/charmbracelet/lipgloss/tree"
t := tree.Root("Root").
Child("Child 1").
Child(
tree.Root("Child 2").
Child("Grandchild 1").
Child("Grandchild 2"),
).
Child("Child 3")
// Custom enumerator
t.Enumerator(tree.RoundedEnumerator) // ├── └──
t.Enumerator(tree.BulletEnumerator) // • bullets
t.Enumerator(tree.NumberedEnumerator) // 1. 2. 3.
// Custom styling
t.EnumeratorStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99")))
t.ItemStyle(lipgloss.NewStyle().Bold(true))
fmt.Println(t)Lip Gloss Table (Static Rendering)
For high-performance static tables (reports, logs). Use for interactive selection.
bubbles/tablego
import "github.com/charmbracelet/lipgloss/table"
t := table.New().
Border(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
Headers("NAME", "AGE", "CITY").
Row("Alice", "30", "NYC").
Row("Bob", "25", "LA").
Wrap(true). // Enable text wrapping
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return lipgloss.NewStyle().Bold(true)
}
return lipgloss.NewStyle()
})
fmt.Println(t)When to use which table:
- : Static rendering, reports, logs, non-interactive
lipgloss/table - : Interactive selection, keyboard navigation, focused rows
bubbles/table
Custom Component Pattern (Sub-Model)
Create reusable Bubble Tea components by exposing , , :
InitUpdateViewgo
// counter.go - Reusable component
package counter
import tea "github.com/charmbracelet/bubbletea"
type Model struct {
Count int
Min int
Max int
}
func New(min, max int) Model {
return Model{Min: min, Max: max}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "+", "=":
if m.Count < m.Max { m.Count++ }
case "-":
if m.Count > m.Min { m.Count-- }
}
}
return m, nil
}
func (m Model) View() string {
return fmt.Sprintf("Count: %d", m.Count)
}
// parent.go - Using the component
type parentModel struct {
counter counter.Model
}
func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.counter, cmd = m.counter.Update(msg)
return m, cmd
}ProgramContext Pattern (Production)
Share global state across components without prop drilling:
go
// context.go
type ProgramContext struct {
Config *Config
Theme *Theme
Width int
Height int
StartTask func(Task) tea.Cmd // Callback for background tasks
}
// model.go
type Model struct {
ctx *ProgramContext
sidebar sidebar.Model
main main.Model
tasks map[string]Task
spinner spinner.Model
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.ctx.Width = msg.Width
m.ctx.Height = msg.Height
// Sync all components
m.sidebar.SetHeight(msg.Height)
m.main.SetSize(msg.Width - sidebarWidth, msg.Height)
}
return m, nil
}
// Initialize context with task callback
func NewModel(cfg *Config) Model {
ctx := &ProgramContext{Config: cfg}
m := Model{ctx: ctx, tasks: make(map[string]Task)}
ctx.StartTask = func(t Task) tea.Cmd {
m.tasks[t.ID] = t
return m.spinner.Tick
}
return m
}Testing with teatest
go
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
func TestModel(t *testing.T) {
m := NewModel()
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
// Send key presses
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
// Wait for specific output
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
return strings.Contains(string(bts), "Selected")
}, teatest.WithDuration(time.Second))
// Check final state
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
final := tm.FinalModel(t).(Model)
if final.selected != "expected" {
t.Errorf("expected 'expected', got %q", final.selected)
}
}SSH Integration with Wish
Serve TUI apps over SSH:
go
import (
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/ssh"
)
func main() {
s, err := wish.NewServer(
wish.WithAddress(":2222"),
wish.WithHostKeyPath(".ssh/term_info_ed25519"),
wish.WithMiddleware(
bubbletea.Middleware(teaHandler),
),
)
if err != nil { log.Fatal(err) }
log.Println("Starting SSH server on :2222")
if err := s.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, _ := s.Pty()
m := NewModel(pty.Window.Width, pty.Window.Height)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}Mouse Zones with bubblezone
Define clickable regions without coordinate math:
go
import zone "github.com/lrstanley/bubblezone"
type model struct {
zone *zone.Manager
}
func newModel() model {
return model{zone: zone.New()}
}
func (m model) View() string {
// Wrap clickable elements
button1 := m.zone.Mark("btn1", "[Button 1]")
button2 := m.zone.Mark("btn2", "[Button 2]")
return lipgloss.JoinHorizontal(lipgloss.Top, button1, " ", button2)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseMsg:
// Check which zone was clicked
if m.zone.Get("btn1").InBounds(msg) {
// Button 1 clicked
}
if m.zone.Get("btn2").InBounds(msg) {
// Button 2 clicked
}
}
return m, nil
}
// Wrap program with zone.NewGlobal() for simpler API
func main() {
zone.NewGlobal()
p := tea.NewProgram(newModel(), tea.WithMouseCellMotion())
p.Run()
}