| name | lipgloss |
| description | Style, measure, and lay out terminal UI content with charmbracelet/lipgloss v2. Covers the render pipeline, layout composition (Join/Place), sizing discipline (Width vs MaxWidth, frame-size accounting), borders, alignment, color system, table/tree/list sub-packages, canvas/compositor, and common measurement pitfalls. Use whenever styling TUI output, debugging layout/overflow bugs, building tables or trees, or compositing layered content. |
Lipgloss v2 — Terminal Styling & Layout
Lipgloss turns strings into styled, measured, composited terminal blocks. Everything is a string in, string out pipeline — no widget tree, no retained state. Style is a value type: every setter returns a new copy.
import "charm.land/lipgloss/v2"
When to Use This Skill
- Styling or rendering any TUI output (colors, borders, padding)
- Building layouts with
JoinHorizontal / JoinVertical / Place
- Debugging alignment, overflow, or sizing bugs
- Measuring rendered content width/height
- Creating tables, trees, or lists with the sub-packages
- Compositing layered content with Canvas/Compositor
- Choosing between
Width vs MaxWidth, padding vs margin, Place vs Join
In This Repo (sci-go)
This skill covers lipgloss mechanics — the string-in/string-out primitives. In sci-go you rarely call them raw: internal/uikit/ wraps them into a small layout system, and the bubbletea skill owns the TUI layer that ties it together. Reach for uikit first; drop to raw lipgloss only when uikit can't express what you need — and then the sizing rules below become yours to enforce again.
| Raw lipgloss (what this skill teaches) | sci-go wrapper (reach for this first) |
|---|
Width(n) minus GetHorizontalFrameSize() by hand | uikit.Box(w, h, style, func(innerW, innerH int) string) — frame overhead subtracted for you |
JoinVertical / JoinHorizontal with manual widths | uikit.VStack(w, h) / uikit.HStack(w, h) with .Fixed / .Flex(ratio) / .Gap children |
PlaceHorizontal for a status bar's left+right | uikit.Spread(width, left, right) |
Place / PlaceHorizontal to center or pad | uikit.Center(width, s) / uikit.Pad(s, width, pos) / uikit.Fit(s, width, pos) |
lipgloss.Wrap | uikit.WordWrap(text, maxW) |
lipgloss.NewStyle() inline | uikit.TUI accessors (.Dim(), .Error(), .TextBlue(), .Base(), …) — inline NewStyle() is lint-banned outside internal/uikit/ |
Skim internal/uikit/doc.go for the full catalog; extend uikit when a pattern appears in ≥ 2 TUIs. The code examples below use lipgloss.NewStyle() directly to teach the mechanics in isolation — translate them to uikit.TUI accessors in real code.
Core Mental Model: The Render Pipeline
When you call style.Render(text), lipgloss applies rules in this exact order:
text
│
├── 1. Transform function (strings.ToUpper, etc.)
├── 2. Tab → spaces conversion
├── 3. Strip newlines (if Inline)
├── 4. Word-wrap to Width (minus horizontal padding)
├── 5. ANSI formatting (bold, italic, colors)
├── 6. Padding (inside border)
├── 7. Vertical alignment (pad to Height)
├── 8. Horizontal alignment (pad to Width)
├── 9. Borders
├── 10. Margins (outside border)
├── 11. MaxWidth truncation (post-render hard clip)
└── 12. MaxHeight truncation (post-render hard clip)
Key insight: Width wraps text before borders. MaxWidth truncates after everything. They are not interchangeable.
Sizing Discipline
This is the single most important section. Most lipgloss layout bugs come from incorrect size accounting.
Width vs MaxWidth
| Property | When applied | What it does | Includes borders? |
|---|
Width(n) | Step 4+8 | Wraps text, then pads all lines to exactly n cells | Yes — borders are subtracted internally before wrapping |
MaxWidth(n) | Step 11 | Hard-truncates the final rendered output | Yes — applied to the complete output |
Rule: Use Width for layout sizing. Use MaxWidth only as a safety clip.
Height vs MaxHeight
Same relationship: Height pads content vertically to fill; MaxHeight truncates the final output.
Frame Size — The Essential Calculation
Every style has a "frame" — the combined size of borders + padding + margins. Use it to calculate content width:
contentWidth := availableWidth - style.GetHorizontalFrameSize()
contentHeight := availableHeight - style.GetVerticalFrameSize()
Available getters for fine-grained control:
| Method | Returns |
|---|
GetFrameSize() | (horizontal, vertical int) |
GetHorizontalFrameSize() | borders + padding + margins (left+right) |
GetVerticalFrameSize() | borders + padding + margins (top+bottom) |
GetHorizontalPadding() | left + right padding |
GetVerticalPadding() | top + bottom padding |
GetHorizontalMargins() | left + right margins |
GetVerticalMargins() | top + bottom margins |
GetHorizontalBorderSize() | left + right border widths |
GetVerticalBorderSize() | top + bottom border widths |
Measuring Rendered Content
Never use len(s) for terminal width. ANSI escapes are invisible; CJK/emoji are 2 cells wide.
w := lipgloss.Width(rendered)
h := lipgloss.Height(rendered)
w, h := lipgloss.Size(rendered)
Common Sizing Mistake
style := lipgloss.NewStyle().Width(20).Border(lipgloss.RoundedBorder())
contentWidth := 20
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Width(contentWidth + style.GetHorizontalBorderSize())
style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder())
style = style.Width(availableWidth)
Layout Composition
JoinHorizontal — Side-by-Side Blocks
result := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
Position controls how shorter blocks align vertically:
Top (0.0) — align to top
Center (0.5) — center vertically
Bottom (1.0) — align to bottom
- Fractional values work:
0.2 = 20% from top
Behavior: All blocks are padded to equal height. All lines padded to equal width with spaces.
Pitfall: The space-padding can bleed background colors. If blocks have different backgrounds, render each with explicit Width first.
JoinVertical — Stacked Blocks
result := lipgloss.JoinVertical(lipgloss.Left, top, bottom)
Position controls how narrower blocks align horizontally:
Left (0.0), Center (0.5), Right (1.0)
Behavior: All lines padded to the width of the widest block.
Place — Position in a Whitespace Box
result := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content)
result := lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, content,
lipgloss.WithWhitespaceStyle(dimStyle),
lipgloss.WithWhitespaceChars("·"),
)
PlaceHorizontal — horizontal-only placement (no height change):
result := lipgloss.PlaceHorizontal(80, lipgloss.Center, content)
PlaceVertical — vertical-only placement (no width change):
result := lipgloss.PlaceVertical(24, lipgloss.Center, content)
Pitfall: Place is a no-op when content already exceeds the given dimensions. It only adds whitespace, never truncates.
Decision Tree: Which Layout Function?
Two rendered blocks, side by side?
→ JoinHorizontal(verticalAlignment, left, right)
Two rendered blocks, stacked?
→ JoinVertical(horizontalAlignment, top, bottom)
Center/position content in available space?
→ Place(w, h, hPos, vPos, content)
→ PlaceHorizontal(w, hPos, content) (height unchanged)
→ PlaceVertical(h, vPos, content) (width unchanged)
Right-align something in a fixed width?
→ PlaceHorizontal(width, lipgloss.Right, content)
→ OR: style.Width(width).Align(lipgloss.Right).Render(content)
Left + right in a status bar?
→ PlaceHorizontal(totalWidth, lipgloss.Left, left + gap + right)
→ OR: render left and right with explicit widths, JoinHorizontal
Padding & Margins
Padding (inside border)
Uses NBSP by default (preserved on copy/paste). CSS shorthand:
style.Padding(1)
style.Padding(1, 2)
style.Padding(1, 2, 3)
style.Padding(1, 2, 3, 4)
style.PaddingTop(1).PaddingRight(2).PaddingBottom(1).PaddingLeft(2)
style.PaddingChar('·')
Margins (outside border)
Uses regular space. Same CSS shorthand as padding:
style.Margin(1, 2)
style.MarginBackground(color)
style.MarginChar('·')
Key difference: Padding is inside the border, margins are outside. Margins are NOT inherited via Inherit().
Borders
Predefined Borders
| Constructor | Visual |
|---|
RoundedBorder() | Rounded corners (most common) |
NormalBorder() | Standard 90-degree corners |
ThickBorder() | Heavy/bold lines |
DoubleBorder() | Double-stroke lines |
HiddenBorder() | Invisible (spaces) — preserves layout sizing |
BlockBorder() | Full block characters |
OuterHalfBlockBorder() | Half-block, outer |
InnerHalfBlockBorder() | Half-block, inner |
ASCIIBorder() | +--+ ASCII |
MarkdownBorder() | Pipe/dash markdown style |
Border Methods
style.Border(lipgloss.RoundedBorder())
style.Border(lipgloss.RoundedBorder(), true, false)
style.BorderTop(true).BorderBottom(true).BorderLeft(false).BorderRight(false)
style.BorderForeground(lipgloss.Color("#7D56F4"))
style.BorderForeground(topColor, rightColor, bottomColor, leftColor)
style.BorderForegroundBlend(startColor, endColor)
style.BorderForegroundBlendOffset(5)
HiddenBorder for Layout Stability
Use HiddenBorder() when toggling between bordered/unbordered states to maintain consistent sizing:
var border lipgloss.Border
if selected {
border = lipgloss.RoundedBorder()
} else {
border = lipgloss.HiddenBorder()
}
style := lipgloss.NewStyle().Border(border)
Border Side Default Behavior
Pitfall: BorderStyle(RoundedBorder()) alone renders ALL 4 sides. The moment you explicitly set ANY side (e.g., BorderTop(true)), only explicitly-enabled sides render — others default to false.
Alignment
style.Align(lipgloss.Center)
style.Align(lipgloss.Center, lipgloss.Center)
style.AlignHorizontal(lipgloss.Right)
style.AlignVertical(lipgloss.Bottom)
Alignment only takes visible effect when Width is set (horizontal) or Height is set (vertical), or when there are multiple lines of different lengths.
Position is a float: Left/Top = 0.0, Center = 0.5, Right/Bottom = 1.0. Fractional values are valid.
Colors (v2)
Constructors
lipgloss.Color("#FF5733")
lipgloss.Color("201")
lipgloss.Color("5")
lipgloss.NoColor{}
lipgloss.RGBColor{R: 255, G: 87, B: 51}
lipgloss.Red, lipgloss.Green, lipgloss.Blue
Adaptive Colors (light/dark terminal)
isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
ld := lipgloss.LightDark(isDark)
fg := ld(lipgloss.Color("#333"), lipgloss.Color("#ccc"))
style := lipgloss.NewStyle().Foreground(fg)
Color Manipulation (v2)
lipgloss.Darken(color, 0.2)
lipgloss.Lighten(color, 0.2)
lipgloss.Alpha(color, 0.5)
lipgloss.Complementary(color)
Gradients (v2)
colors := lipgloss.Blend1D(10, startColor, midColor, endColor)
colors := lipgloss.Blend2D(width, height, 45.0, topLeft, topRight, bottomLeft, bottomRight)
Style Inheritance
base := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFF")).Bold(true)
derived := lipgloss.NewStyle().Italic(true).Inherit(base)
Inherit copies rules from base that aren't already set on derived. Margins and padding are NOT inherited. Background color IS inherited to margin background if neither style has explicitly set one.
Text Formatting
style.Bold(true)
style.Italic(true)
style.Underline(true)
style.UnderlineStyle(lipgloss.UnderlineCurly)
style.UnderlineColor(lipgloss.Color("#F00"))
style.Strikethrough(true)
style.Faint(true)
style.Reverse(true)
style.Blink(true)
style.Hyperlink("https://example.com")
style.Transform(strings.ToUpper)
style.UnderlineSpaces(false)
style.StrikethroughSpaces(false)
ANSI-Aware Text Wrapping
wrapped := lipgloss.Wrap(styledText, 60, "")
w := lipgloss.NewWrapWriter(buf)
defer w.Close()
io.WriteString(w, styledText)
Common Mistakes
See references/pitfalls.md for the complete list with fixes.
1. Using len() instead of lipgloss.Width()
if len(rendered) > maxWidth { ... }
if lipgloss.Width(rendered) > maxWidth { ... }
2. Confusing Width and MaxWidth
style.MaxWidth(40).Border(lipgloss.RoundedBorder())
style.Width(40).Border(lipgloss.RoundedBorder())
3. Forgetting frame size in width calculations
panel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Padding(0, 1).
Width(availableWidth)
content := renderContent(availableWidth)
panel.Render(content)
contentWidth := availableWidth - panel.GetHorizontalFrameSize()
content := renderContent(contentWidth)
panel.Render(content)
4. Manual gap calculation instead of Place
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
result := left + strings.Repeat(" ", max(0, gap)) + right
leftRendered := leftStyle.Width(lipgloss.Width(left)).Render(left)
rightRendered := rightStyle.Width(lipgloss.Width(right)).Render(right)
result := lipgloss.PlaceHorizontal(width, lipgloss.Left,
lipgloss.JoinHorizontal(lipgloss.Top, leftRendered,
lipgloss.PlaceHorizontal(width-lipgloss.Width(left), lipgloss.Right, right)))
result := lipgloss.NewStyle().Width(width).Render(
lipgloss.JoinHorizontal(lipgloss.Top, left,
lipgloss.NewStyle().
Width(width-lipgloss.Width(left)).
Align(lipgloss.Right).
Render(right)))
5. Setting Height on bordered styles
style := lipgloss.NewStyle().Border(border).Height(h)
for len(lines) < innerHeight {
lines = append(lines, "")
}
style := lipgloss.NewStyle().Border(border)
result := style.Render(strings.Join(lines, "\n"))
6. Constructing styles inline (sci-go convention)
In this repo, inline lipgloss.NewStyle() is allowed only inside internal/uikit/*.go — anywhere else it fails the lint gate (rules/no-inline-newstyle.yml). The point is one palette and one set of semantic styles, resolved once for light/dark terminals, rather than ad-hoc colors scattered across packages. Use the uikit.TUI singleton: semantic styles via accessors (uikit.TUI.Dim(), .Error(), .TextBlue(), …) and a raw container to build on via uikit.TUI.Base(). There is no per-TUI ui/ package — styles never live next to the model. See the "In This Repo" section above and internal/uikit/doc.go.
Reference Files
references/pitfalls.md — Complete pitfall catalog with before/after fixes
references/table-tree-list.md — Table, Tree, and List sub-package API reference
references/canvas-compositor.md — Canvas, Layer, and Compositor API for composited layouts
references/color-system.md — Full color constructor catalog, adaptive colors, gradients, blending