| name | bubbles-v2 |
| description | Generates, explains, and debugs Bubble Tea component code using Charm's Bubbles v2 library for Go. Use when building TUI apps that need spinners, text inputs, text areas, lists, tables, progress bars, viewports, file pickers, timers, stopwatches, help views, paginators, or keybindings. Covers the v2 API at charm.land/bubbles/v2, migration from v1, light/dark theme handling, and all component patterns. |
| argument-hint | ["component|pattern|feature description"] |
| allowed-tools | Read, Grep, Bash(go doc *) |
| metadata | {"author":"skill-generator","version":"2.0","category":"go-cli"} |
Bubbles v2 โ Pre-Built Components for Bubble Tea
Bubbles v2 (charm.land/bubbles/v2) provides ready-made TUI components for
Bubble Tea v2 applications. Requires Bubble Tea v2 + Lip Gloss v2.
Quick Reference
For detailed go doc output, run:
go doc --all charm.land/bubbles/v2/<component>
Migrating from Bubbles v1?
If the codebase uses github.com/charmbracelet/bubbles, read the migration
guide before making any changes: references/MIGRATION.md
Search-and-replace import paths:
github.com/charmbracelet/bubbles/ โ charm.land/bubbles/v2/
github.com/charmbracelet/bubbles โ charm.land/bubbles/v2
Upgrade all three together:
go get charm.land/bubbletea/v2
go get charm.land/bubbles/v2
go get charm.land/lipgloss/v2
Key breaking changes at a glance:
| Area | v1 | v2 |
|---|
| Import prefix | github.com/charmbracelet/bubbles | charm.land/bubbles/v2 |
| Key press type | case tea.KeyMsg: | case tea.KeyPressMsg: |
| Width/Height | exported fields (m.Width = 40) | getter/setter methods (m.SetWidth(40)) |
| DefaultKeyMap | variable | function: DefaultKeyMap() |
| NewModel | deprecated alias | removed โ use New() |
| Adaptive colors | AdaptiveColor (auto) | DefaultStyles(isDark bool) (explicit) |
| Progress colors | string hex | lipgloss.Color / color.Color |
| Viewport ctor | New(w, h int) | New(...Option) |
| Stopwatch ctor | NewWithInterval(d) | New(WithInterval(d)) |
| Timer ctor | NewWithInterval(t,i) | New(t, WithInterval(i)) |
| Spinner tick | spinner.Tick() (pkg func) | model.Tick() (method) |
Light and Dark Styles
Critical: Bubbles v2 removes automatic color adaptation. You must pass
isDark bool to style constructors. Always handle tea.BackgroundColorMsg:
func (m model) Init() tea.Cmd {
return tea.RequestBackgroundColor
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.BackgroundColorMsg:
isDark := msg.IsDark()
m.help.Styles = help.DefaultStyles(isDark)
m.list.Styles = list.DefaultStyles(isDark)
m.textInput.SetStyles(textinput.DefaultStyles(isDark))
m.textArea.SetStyles(textarea.DefaultStyles(isDark))
}
return m, nil
}
Quick alternative (blocks, not suitable for SSH/Wish):
import "charm.land/lipgloss/v2/compat"
isDark := compat.HasDarkBackground()
Component Overview
| Component | Constructor | Purpose |
|---|
spinner | spinner.New(opts...) | Animated loading indicator |
textinput | textinput.New() | Single-line text input |
textarea | textarea.New() | Multi-line text input |
list | list.New(items, delegate, w, h) | Browseable item list with filter |
table | table.New(opts...) | Scrollable tabular data |
progress | progress.New(opts...) | Progress bar (animated or static) |
viewport | viewport.New(opts...) | Scrollable content area |
filepicker | filepicker.New() | File system navigator |
paginator | paginator.New(opts...) | Pagination logic + display |
help | help.New() | Auto-generated keybinding help |
key | key.NewBinding(opts...) | Keybinding definitions |
timer | timer.New(timeout, opts...) | Countdown timer |
stopwatch | stopwatch.New(opts...) | Count-up stopwatch |
cursor | (embedded in inputs) | Text cursor behavior |
Spinner
import "charm.land/bubbles/v2/spinner"
sp := spinner.New(
spinner.WithSpinner(spinner.Dot),
spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("205"))),
)
return sp.Tick()
case spinner.TickMsg:
sp, cmd = sp.Update(msg)
return m, cmd
sp.View()
Text Input
import "charm.land/bubbles/v2/textinput"
ti := textinput.New()
ti.Placeholder = "Enter text..."
ti.SetWidth(40)
ti.CharLimit = 156
ti.EchoMode = textinput.EchoPassword
ti.SetStyles(textinput.DefaultStyles(isDark))
cmd := ti.Focus()
ti.Blur()
ti.Focused()
case tea.KeyPressMsg:
ti, cmd = ti.Update(msg)
ti.Value()
ti.SetValue("initial")
ti.Reset()
ti.SetSuggestions([]string{"foo", "bar"})
ti.ShowSuggestions = true
ti.SetVirtualCursor(false)
f.Cursor = ti.Cursor()
Text Area
import "charm.land/bubbles/v2/textarea"
ta := textarea.New()
ta.Placeholder = "Enter some text..."
ta.SetWidth(60)
ta.SetHeight(10)
ta.ShowLineNumbers = true
ta.SetStyles(textarea.DefaultStyles(isDark))
cmd := ta.Focus()
ta.Blur()
ta, cmd = ta.Update(msg)
ta.Value()
ta.SetValue("initial content")
ta.Line()
ta.Column()
ta.MoveToBegin()
ta.MoveToEnd()
km := textarea.DefaultKeyMap()
km.InsertNewline.SetEnabled(false)
ta.KeyMap = km
List
import "charm.land/bubbles/v2/list"
type item struct{ title, desc string }
func (i item) FilterValue() string { return i.title }
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
items := []list.Item{item{"Foo", "A thing"}, item{"Bar", "Another"}}
delegate := list.NewDefaultDelegate()
l := list.New(items, delegate, width, height)
l.Title = "My List"
l.Styles = list.DefaultStyles(isDark)
delegate.Styles = list.NewDefaultItemStyles(isDark)
l, cmd = l.Update(msg)
if i, ok := l.SelectedItem().(item); ok { ... }
cmd = l.SetItems(newItems)
cmd = l.InsertItem(0, newItem)
l.RemoveItem(idx)
l.FilterState()
l.SetFilteringEnabled(false)
l.SetShowTitle(false)
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetShowPagination(false)
l.DisableQuitKeybindings()
Table
import "charm.land/bubbles/v2/table"
cols := []table.Column{
{Title: "Name", Width: 20},
{Title: "Age", Width: 5},
}
rows := []table.Row{{"Alice", "30"}, {"Bob", "25"}}
t := table.New(
table.WithColumns(cols),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(10),
table.WithWidth(40),
table.WithStyles(table.DefaultStyles()),
)
t.SetStyles(table.Styles{
Header: lipgloss.NewStyle().Bold(true),
Cell: lipgloss.NewStyle(),
Selected: lipgloss.NewStyle().Reverse(true),
})
t, cmd = t.Update(msg)
t.SelectedRow()
t.Cursor()
t.Rows()
t.SetRows(newRows)
t.SetCursor(n)
t.GotoTop() / t.GotoBottom()
Progress Bar
import "charm.land/bubbles/v2/progress"
p := progress.New(
progress.WithColors(lipgloss.Color("#5A56E0"), lipgloss.Color("#EE6FF8")),
progress.WithoutPercentage(),
progress.WithWidth(40),
)
view := p.ViewAs(0.7)
p := progress.New(progress.WithDefaultBlend())
case progress.FrameMsg:
pm, cmd := p.Update(msg)
p = pm.(progress.Model)
return m, cmd
cmd = p.SetPercent(0.9)
cmd = p.IncrPercent(0.1)
cmd = p.DecrPercent(0.1)
p := progress.New(progress.WithColorFunc(func(total, current float64) color.Color {
if total < 0.5 { return lipgloss.Color("#FF0000") }
return lipgloss.Color("#00FF00")
}))
Viewport
import "charm.land/bubbles/v2/viewport"
vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(24))
vp := viewport.New()
vp.SetWidth(80)
vp.SetHeight(24)
vp.SetContent(longString)
vp.SetContentLines(lines)
vp.SoftWrap = true
vp.MouseWheelEnabled = true
vp.LeftGutterFunc = func(info viewport.GutterContext) string {
if info.Soft { return " โ " }
if info.Index >= info.TotalLines { return " ~ โ " }
return fmt.Sprintf("%4d โ ", info.Index+1)
}
vp.SetHighlights(re.FindAllStringIndex(vp.GetContent(), -1))
vp.HighlightNext()
vp.HighlightPrevious()
vp.ClearHighlights()
vp.StyleLineFunc = func(i int) lipgloss.Style { ... }
vp.GotoTop() / vp.GotoBottom()
vp.ScrollPercent()
vp.SetYOffset(n)
Help and Keybindings
import "charm.land/bubbles/v2/help"
import "charm.land/bubbles/v2/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Quit key.Binding
}
func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{k.Up, k.Down, k.Quit} }
func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{{k.Up, k.Down}, {k.Quit}} }
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("k", "up"), key.WithHelp("โ/k", "move up")),
Down: key.NewBinding(key.WithKeys("j", "down"), key.WithHelp("โ/j", "move down")),
Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
}
case tea.KeyPressMsg:
switch {
case key.Matches(msg, keys.Up): ...
case key.Matches(msg, keys.Down): ...
case key.Matches(msg, keys.Quit): return m, tea.Quit
}
h := help.New()
h.Styles = help.DefaultStyles(isDark)
h.SetWidth(width)
view := h.View(keys)
Paginator
import "charm.land/bubbles/v2/paginator"
p := paginator.New(
paginator.WithPerPage(10),
paginator.WithTotalPages(5),
)
p.Type = paginator.Dots
p.SetTotalPages(len(items))
start, end := p.GetSliceBounds(len(items))
pageItems := items[start:end]
km := paginator.DefaultKeyMap()
p.KeyMap = km
p, cmd = p.Update(msg)
p.OnFirstPage() / p.OnLastPage()
p.PrevPage() / p.NextPage()
Timer, Stopwatch, File Picker
t := timer.New(30*time.Second)
t := timer.New(30*time.Second, timer.WithInterval(100*time.Millisecond))
sw := stopwatch.New()
sw := stopwatch.New(stopwatch.WithInterval(500*time.Millisecond))
fp := filepicker.New()
fp.AllowedTypes = []string{".go", ".md"}
fp.SetHeight(10)
See full API in references/COMPONENTS.md.
Embedding Components
Route messages to sub-components in Update; compose views in View:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
if m.input.Focused() {
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m model) View() string {
return lipgloss.JoinVertical(lipgloss.Left,
m.spinner.View(),
m.input.View(),
m.help.View(m.keys),
)
}