Integration Cookbook
Working multi-library code examples. Import paths verified against go.mod.
1. Standard TUI App (bubbletea + lipgloss + bubbles)
A list with styled header and footer. The three core libraries working together.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/bubbles/v2/list"
"charm.land/lipgloss/v2"
)
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1)
statusStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
)
type item string
func (i item) FilterValue() string { return string(i) }
func (i item) Title() string { return string(i) }
func (i item) Description() string { return "" }
type model struct {
list list.Model
width int
height int
}
func initialModel() model {
items := []list.Item{
item("Buy groceries"),
item("Clean the house"),
item("Write some Go"),
item("Touch grass"),
}
l := list.New(items, list.NewDefaultDelegate(), 40, 15)
l.Title = "Tasks"
return model{list: l}
}
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.KeyPressMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.SetSize(msg.Width, msg.Height-2)
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
header := titleStyle.Render("My App")
body := m.list.View()
footer := statusStyle.Render("q to quit")
v := tea.NewView(fmt.Sprintf("%s\n%s\n%s", header, body, footer))
v.AltScreen = true
return v
}
func main() {
if _, err := tea.NewProgram(initialModel()).Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
2. Forms Embedded in TUI (bubbletea + huh)
A bubbletea app that shows a huh form, then displays results.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
)
type state int
const (
stateForm state = iota
stateDone
)
type model struct {
form *huh.Form
state state
name string
lang string
}
func newModel() model {
var name, lang string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Key("name").
Title("Your name").
Value(&name),
huh.NewSelect[string]().
Key("lang").
Title("Favorite language").
Options(huh.NewOptions("Go", "Rust", "Python", "TypeScript")...).
Value(&lang),
),
)
return model{form: form, name: name, lang: lang}
}
func (m model) Init() tea.Cmd {
return m.form.Init()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
}
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
}
if m.form.State == huh.StateCompleted {
m.state = stateDone
m.name = m.form.GetString("name")
m.lang = m.form.GetString("lang")
return m, tea.Quit
}
return m, cmd
}
func (m model) View() tea.View {
if m.state == stateDone {
result := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).
Render(fmt.Sprintf("%s loves %s!", m.name, m.lang))
return tea.NewView(result + "\n")
}
return tea.NewView(m.form.View())
}
func main() {
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
3. Scrollable Markdown Viewer (bubbletea + glamour + viewport)
Renders markdown with glamour, displays in a scrollable viewport.
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"charm.land/bubbles/v2/viewport"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
)
const sampleMD = `# Welcome
This is a **scrollable** markdown viewer built with:
- Bubble Tea (framework)
- Glamour (markdown rendering)
- Bubbles viewport (scrolling)
## Features
Scroll with j/k or arrow keys. Press q to quit.
> "The terminal is the ultimate UI." - someone, probably
## Code Example
` + "```go" + `
fmt.Println("Hello from glamour!")
` + "```" + `
More content here to make it scrollable...
`
type model struct {
viewport viewport.Model
rawMarkdown string
ready bool
}
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.KeyPressMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
if !m.ready {
m.viewport = viewport.New(
viewport.WithWidth(msg.Width),
viewport.WithHeight(msg.Height-2),
)
m.ready = true
} else {
m.viewport.SetWidth(msg.Width)
m.viewport.SetHeight(msg.Height - 2)
}
r, _ := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(msg.Width-4),
)
rendered, _ := r.Render(m.rawMarkdown)
m.viewport.SetContent(rendered)
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
if !m.ready {
return tea.NewView("Loading...")
}
header := lipgloss.NewStyle().Bold(true).
Foreground(lipgloss.Color("205")).
Render("Markdown Viewer")
footer := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render(fmt.Sprintf("scroll: %d%%", int(m.viewport.ScrollPercent()*100)))
v := tea.NewView(header + "\n" + m.viewport.View() + "\n" + footer)
v.AltScreen = true
return v
}
func main() {
m := model{rawMarkdown: sampleMD}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
4. Animated Transitions (bubbletea + harmonica)
Spring-animated horizontal bar that follows a target position.
package main
import (
"fmt"
"math"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/harmonica"
)
const fps = 60
type frameMsg time.Time
func animate() tea.Cmd {
return tea.Tick(time.Second/fps, func(t time.Time) tea.Msg {
return frameMsg(t)
})
}
type model struct {
spring harmonica.Spring
x float64
xVel float64
target float64
width int
}
func (m model) Init() tea.Cmd { return animate() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "left":
m.target = float64(m.width) * 0.2
case "right":
m.target = float64(m.width) * 0.8
case "space":
m.target = float64(m.width) * 0.5
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.target = float64(msg.Width) * 0.5
case frameMsg:
m.x, m.xVel = m.spring.Update(m.x, m.xVel, m.target)
if math.Abs(m.x-m.target) < 0.01 && math.Abs(m.xVel) < 0.01 {
return m, nil // stop animating when settled
}
return m, animate()
}
return m, nil
}
func (m model) View() tea.View {
if m.width == 0 {
return tea.NewView("")
}
pos := int(m.x)
if pos < 0 {
pos = 0
}
if pos >= m.width {
pos = m.width - 1
}
bar := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true).
Render("@")
line := strings.Repeat(" ", pos) + bar
help := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).
Render("\nleft/right/space to move, q to quit")
return tea.NewView(line + help)
}
func main() {
m := model{
spring: harmonica.NewSpring(harmonica.FPS(fps), 7.0, 0.35),
}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
5. Scripted Demo Recording (gum + vhs)
A VHS tape that demos a gum-powered script.
Output demo.gif
Set FontSize 14
Set Width 1000
Set Height 500
Set Theme "Catppuccin Frappe"
Set WindowBar Colorful
Set TypingSpeed 0.06
Set Framerate 30
Require gum
# Show a styled banner
Type `gum style --foreground 212 --border rounded --padding "1 2" "Deploy Helper"`
Enter
Sleep 1s
# Choose environment
Type `ENV=$(gum choose "staging" "production")`
Enter
Sleep 500ms
Down
Sleep 300ms
Enter
Sleep 1s
# Confirm
Type `gum confirm "Deploy to $ENV?" && echo "Deploying..." || echo "Cancelled"`
Enter
Sleep 500ms
Enter
Sleep 2s