Charm Bracelet
Examples

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, or tab → move right
  • left, h, p, or shift+tab → move left
  • ctrl+c or q → 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:

  1. Loops over all tabs and applies the correct style (active or inactive).
  2. Joins them horizontally into a single tab bar.
  3. 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.

tabs example demonstration

Final Code

main.go
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?