Tabs
What This Example Shows
This example demonstrates how to build a tabbed interface in a Bubble Tea terminal UI using the lipgloss library for styling. Tabs let you switch between multiple sections of content without leaving the current screen.
The key here is combining Bubble Tea's state management with Lip Gloss's styling to render active and inactive tabs dynamically.
Why Tabs Matter in TUIs
Tabs are a common UI pattern in GUIs, but less common in terminal apps. They allow multiple views to coexist in one layout, improving navigation and organization.
Without careful state management, switching tabs could break alignment or lose track of which tab is active.
Here’s what happens:
- Each tab has a label and content.
- The active tab is tracked in the model.
- Key presses update which tab is active.
- Lip Gloss dynamically renders borders so the active tab connects seamlessly with its content window.
How It Works
The App State
We keep track of the tabs and which one is active:
type model struct {
Tabs []string // Tab labels
TabContent []string // Content for each tab
activeTab int // Index of current tab
}Handling Key Presses
The Update function listens for navigation keys:
right,l,n, ortab→ move rightleft,h,p, orshift+tab→ move leftctrl+corq→ quit
Bounds are checked so you can’t scroll past the first/last tab.
Styling Tabs with Lip Gloss
We define two styles:
inactiveTabStyle: Tabs not in focus, with a solid bottom border.activeTabStyle: The selected tab, with an open bottom border that connects to the content window.
The trick is modifying border characters (┴, ┘, └, etc.) so the tabs merge cleanly with the window below.
Rendering the View
The View function:
- Loops over all tabs and applies the correct style (active or inactive).
- Joins them horizontally into a single tab bar.
- Renders the content window below, sized to match the tab row.
The result is a clean, dynamic tabbed interface where users can switch sections easily.
Code Breakdown
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch keypress := msg.String(); keypress {
case "ctrl+c", "q":
return m, tea.Quit
case "right", "l", "n", "tab":
m.activeTab = min(m.activeTab+1, len(m.Tabs)-1)
return m, nil
case "left", "h", "p", "shift+tab":
m.activeTab = max(m.activeTab-1, 0)
return m, nil
}
}
return m, nil
}This handles tab navigation and quitting.
func tabBorderWithBottom(left, middle, right string) lipgloss.Border {
border := lipgloss.RoundedBorder()
border.BottomLeft = left
border.Bottom = middle
border.BottomRight = right
return border
}
var (
inactiveTabBorder = tabBorderWithBottom("┴", "─", "┴")
activeTabBorder = tabBorderWithBottom("┘", " ", "└")
inactiveTabStyle = lipgloss.NewStyle().
Border(inactiveTabBorder, true).
BorderForeground(highlightColor).
Padding(0, 1)
activeTabStyle = inactiveTabStyle.Border(activeTabBorder, true)
)Defines how active and inactive tabs look.
func (m model) View() string {
doc := strings.Builder{}
var renderedTabs []string
for i, t := range m.Tabs {
style := inactiveTabStyle
if i == m.activeTab {
style = activeTabStyle
}
// Adjust borders for first/last tabs
border, _, _, _, _ := style.GetBorder()
if i == 0 && i == m.activeTab {
border.BottomLeft = "│"
} else if i == 0 {
border.BottomLeft = "├"
} else if i == len(m.Tabs)-1 && i == m.activeTab {
border.BottomRight = "│"
} else if i == len(m.Tabs)-1 {
border.BottomRight = "┤"
}
style = style.Border(border)
renderedTabs = append(renderedTabs, style.Render(t))
}
row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
doc.WriteString(row)
doc.WriteString("\n")
doc.WriteString(windowStyle.Width(lipgloss.Width(row)-windowStyle.GetHorizontalFrameSize()).Render(m.TabContent[m.activeTab]))
return docStyle.Render(doc.String())
}This builds the tab row and content window.
Final Code
package main
import (
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type model struct {
Tabs []string
TabContent []string
activeTab int
}
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 "right", "l", "n", "tab":
m.activeTab = min(m.activeTab+1, len(m.Tabs)-1)
return m, nil
case "left", "h", "p", "shift+tab":
m.activeTab = max(m.activeTab-1, 0)
return m, nil
}
}
return m, nil
}
func tabBorderWithBottom(left, middle, right string) lipgloss.Border {
border := lipgloss.RoundedBorder()
border.BottomLeft = left
border.Bottom = middle
border.BottomRight = right
return border
}
var (
inactiveTabBorder = tabBorderWithBottom("┴", "─", "┴")
activeTabBorder = tabBorderWithBottom("┘", " ", "└")
docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2)
highlightColor = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
inactiveTabStyle = lipgloss.NewStyle().
Border(inactiveTabBorder, true).
BorderForeground(highlightColor).
Padding(0, 1)
activeTabStyle = inactiveTabStyle.Border(activeTabBorder, true)
windowStyle = lipgloss.NewStyle().
BorderForeground(highlightColor).
Padding(2, 0).
Align(lipgloss.Center).
Border(lipgloss.NormalBorder()).
UnsetBorderTop()
)
func (m model) View() string {
doc := strings.Builder{}
var renderedTabs []string
for i, t := range m.Tabs {
style := inactiveTabStyle
if i == m.activeTab {
style = activeTabStyle
}
border, _, _, _, _ := style.GetBorder()
if i == 0 && i == m.activeTab {
border.BottomLeft = "│"
} else if i == 0 {
border.BottomLeft = "├"
} else if i == len(m.Tabs)-1 && i == m.activeTab {
border.BottomRight = "│"
} else if i == len(m.Tabs)-1 {
border.BottomRight = "┤"
}
style = style.Border(border)
renderedTabs = append(renderedTabs, style.Render(t))
}
row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
doc.WriteString(row)
doc.WriteString("\n")
doc.WriteString(windowStyle.Width(lipgloss.Width(row)-windowStyle.GetHorizontalFrameSize()).Render(m.TabContent[m.activeTab]))
return docStyle.Render(doc.String())
}
func main() {
tabs := []string{"Lip Gloss", "Blush", "Eye Shadow", "Mascara", "Foundation"}
tabContent := []string{"Lip Gloss Tab", "Blush Tab", "Eye Shadow Tab", "Mascara Tab", "Foundation Tab"}
m := model{Tabs: tabs, TabContent: tabContent}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}How is this guide?