Skip to main content

Themes & Styles

The Style struct

latte.Style is the complete visual description of any component:

type Style struct {
FG, BG latte.Color
Bold, Italic, Underline, Blink, Reverse bool
Padding, Margin latte.Insets
Border latte.BorderStyle
BorderFG, BorderBG latte.Color
TextAlign latte.Alignment
}

The zero value of Style means "inherit / use default". A field only overrides the default when it is non-zero. This makes styles composable without touching fields you do not care about.

Colors

latte.RGB(30, 120, 255)    // true-color (24-bit)
latte.Hex("#1E78FF") // true-color from a hex string
latte.ColorCyan // ANSI-16 named colors
latte.ColorBrightWhite // ANSI-16 bright variants

True-color requires a modern terminal (e.g. iTerm2, kitty, Windows Terminal, most Linux terminals). latte.ThemeDefault uses only ANSI-16 and works everywhere.

Named color palette

latte/colors.go provides ~120 named Color constants so you can reference semantic names instead of raw hex strings. All constants are latte.Color values produced via latte.RGB and work with any API that accepts a latte.Color.

Utility scales

Tailwind-style shade scales (50–950): Slate, Zinc, Stone, Sky, Blue, Indigo, Cornflower, Cyan, Teal, Emerald, Green, Lime, Yellow, Amber, Orange, Red, Rose, Pink, Violet, Purple, Fuchsia.

latte.Pink500       // RGB(236, 72, 153)
latte.Slate800 // RGB(30, 41, 59)
latte.Emerald400 // RGB(52, 211, 153)
latte.Cornflower400 // RGB(100, 149, 237) — classic TUI focus blue

Design-system palettes

Named constants extracted directly from the four true-color built-in themes, grouped by theme family:

FamilyExample constants
Dark*DarkBg, DarkAccent, DarkMuted, DarkSuccess, DarkWarning, DarkError, DarkBgElevated, DarkBgScrim, DarkBorder
Light*LightBg, LightBgElevated, LightBgSelected, LightBgHeader, LightBgScrim, LightBorder, LightMuted, LightText, LightAccent, LightSuccess, LightWarning, LightError
Dracula*DraculaBg, DraculaPurple, DraculaCyan, DraculaGreen, DraculaFg, DraculaComment, DraculaSelection, DraculaOrange, DraculaRed, DraculaYellow, DraculaPink
Nord0Nord15NordBg, NordBgElevated, NordBgScrim
// Derive a custom theme using named constants instead of raw hex literals.
myTheme := latte.ThemeDark.
WithAccent(latte.Style{FG: latte.Pink500}).
WithFocusBorder(latte.Pink500).
WithName("dark-pink")

// Mix palette families — e.g. Nord background with Dracula accents.
hybrid := latte.ThemeNord.
WithAccent(latte.Style{FG: latte.DraculaPurple}).
WithFocusBorder(latte.DraculaCyan).
WithName("nord-dracula")

Borders

ConstantRunesNotes
latte.BorderNoneZero value; inherits from theme
latte.BorderExplicitNoneActively suppresses the border
latte.BorderSingle┌─┐│└─┘
latte.BorderRounded╭─╮│╰─╯Default for dialogs
latte.BorderDouble╔═╗║╚═╝
latte.BorderThick┏━┓┃┗━┛
latte.BorderDashed┌╌┐╎└╌┘

Use latte.BorderExplicitNone to suppress a border that a theme would otherwise add:

// No box drawn, even if the theme sets a border on Input.
input := widget.NewEditText().
WithStyle(latte.Style{Border: latte.BorderExplicitNone})

Style.Merge

Non-zero fields from override replace the corresponding fields in base. Zero fields in override leave base untouched. Calling base.Merge(override) is how theme tokens cascade without clobbering explicit per-widget settings.

warning

In ApplyTheme, always merge onto callerStyle — never assign the theme token directly, and never merge onto w.Style:

// Correct — theme is the base; the caller's original intent survives theme switches.
func (w *MyWidget) ApplyTheme(t latte.Theme) {
w.Style = t.Input.Merge(w.callerStyle)
}

// Wrong — merging onto w.Style accumulates stale values from the previous theme.
// On the second SetTheme call the old theme's colours stick and the new theme
// cannot fully replace them.
func (w *MyWidget) ApplyTheme(t latte.Theme) {
w.Style = t.Input.Merge(w.Style)
}

// Also wrong — overwrites BorderExplicitNone and any other caller-set field.
func (w *MyWidget) ApplyTheme(t latte.Theme) {
w.Style = t.Input
}

Built-in themes

Five themes ship out of the box:

ThemePaletteTerminal requirement
latte.ThemeDefaultANSI-16Any terminal
latte.ThemeDarkTrue-color, deep navy / blue-cyanTrue-color terminal
latte.ThemeLightTrue-color, warm off-white / indigoTrue-color terminal
latte.ThemeDraculaTrue-color, Dracula paletteTrue-color terminal
latte.ThemeNordTrue-color, Nord arctic paletteTrue-color terminal

Applying a theme at construction

Pass a theme via oat.WithTheme(t). The canvas walks the entire component tree and calls ApplyTheme on every node that implements ThemeReceiver.

app := oat.NewCanvas(
oat.WithTheme(latte.ThemeDark),
oat.WithBody(body),
)

Switching themes at runtime

Call app.SetTheme(t) to replace the active theme at any time — for example from a key binding. The new theme is immediately re-applied to the entire tree, including all mounted overlays and persistent overlays. The canvas background is also updated.

themes := []latte.Theme{
latte.ThemeDark,
latte.ThemeLight,
latte.ThemeDracula,
latte.ThemeNord,
}
current := 0

app := oat.NewCanvas(
oat.WithTheme(themes[current]),
oat.WithBody(body),
oat.WithGlobalKeyBinding(oat.KeyBinding{
Key: tcell.KeyCtrlT,
Mod: tcell.ModCtrl,
Label: "^T",
Description: "Toggle theme",
Handler: func() {
current = (current + 1) % len(themes)
app.SetTheme(themes[current])
},
}),
)

SetTheme is safe to call from any key-event callback — it runs on the main goroutine and the event loop re-renders on the next tick automatically.

Reading the active theme

Call app.GetTheme() to retrieve a pointer to the currently active theme. This is useful when you need to derive a modified variant from the current theme rather than hardcoding a specific one.

// Derive a borderless variant of whatever theme is currently active.
if t := app.GetTheme(); t != nil {
borderless := t.
WithPanel(latte.Style{Border: latte.BorderExplicitNone}).
WithInput(latte.Style{Border: latte.BorderExplicitNone}).
WithButton(latte.Style{Border: latte.BorderExplicitNone}).
WithName(t.Name + "-borderless")
app.SetTheme(borderless)
}

GetTheme returns nil if NewCanvas was called without WithTheme and SetTheme has never been invoked. Always guard with a nil check before dereferencing the pointer.

Semantic theme tokens

Themes expose named style tokens — roles rather than components — so the same theme drives the entire UI consistently:

TokenUsed by
CanvasFull-screen background
Text, MutedBody copy, secondary text
Accent, Success, Warning, ErrorState colors
Panel, PanelTitlelayout.Border containers
Input, InputFocuswidget.EditText
ListSelectedwidget.List selected row
Button, ButtonFocuswidget.Button
CheckBox, CheckBoxFocuswidget.CheckBox
Header, FooterCanvas header / footer
FocusBorderlayout.Border when a descendant is focused
Dialog, DialogTitle, Scrimwidget.Dialog and its backdrop
Tagwidget.Label chips
NotificationInfo/Success/Warning/Errorwidget.NotificationManager banners

Applying a theme to a custom widget

Pick the token that best describes your widget's role and apply it via Merge:

func (w *MyWidget) ApplyTheme(t latte.Theme) {
// Always merge onto callerStyle (the original caller-set value), not w.Style.
// Using w.Style would accumulate stale colours from the previous theme on
// every SetTheme call.
w.Style = t.Input.Merge(w.callerStyle)
w.FocusStyle = t.InputFocus.Merge(w.callerFocusStyle)
}

The framework calls ApplyTheme automatically when:

  • the canvas starts via WithTheme, and
  • app.SetTheme(t) is called at runtime.

You do not need to call it yourself.

Deriving a custom theme

Every built-in theme exposes a With<Token> builder method for each of its fields. All methods return a new Theme by value — the originals are never mutated.

Style-typed tokens accept a latte.Style and use Style.Merge semantics: only the non-zero fields you supply are overridden; the rest of the token is left exactly as the base theme defined it.

// Start from a built-in theme and make targeted tweaks.
myTheme := latte.ThemeNord.
WithAccent(latte.Style{FG: latte.Hex("#ff69b4")}). // swap accent colour only
WithFocusBorder(latte.Hex("#ff69b4")). // keep Nord's FG, just change focus ring
WithName("nord-pink")

Removing borders globally

Pass latte.Style{Border: latte.BorderExplicitNone} to the tokens that carry borders. BorderExplicitNone propagates through Merge and actively suppresses any border that the base theme would draw:

borderless := latte.ThemeNord.
WithPanel(latte.Style{Border: latte.BorderExplicitNone}).
WithInput(latte.Style{Border: latte.BorderExplicitNone}).
WithButton(latte.Style{Border: latte.BorderExplicitNone}).
WithDialog(latte.Style{Border: latte.BorderExplicitNone}).
WithName("nord-borderless")

app := oat.NewCanvas(oat.WithTheme(borderless), oat.WithBody(body))

Changing the background

deepBlack := latte.ThemeDark.
WithCanvas(latte.Style{BG: latte.Hex("#000000")}).
WithName("dark-deep")

Full list of builder methods

MethodToken typeNotes
WithName(string)Sets the theme name
WithCanvas(Style)StyleFull-screen background
WithText(Style)StyleBody text
WithMuted(Style)StyleSecondary / de-emphasised text
WithAccent(Style)StylePrimary interactive colour
WithSuccess(Style)StylePositive state
WithWarning(Style)StyleCautionary state
WithError(Style)StyleDestructive / critical state
WithPanel(Style)Stylelayout.Border containers
WithPanelTitle(Style)StyleTitle text in panel borders
WithInput(Style)Stylewidget.EditText base
WithInputFocus(Style)Stylewidget.EditText focused overlay
WithListSelected(Style)Stylewidget.List selected row
WithButton(Style)Stylewidget.Button base
WithButtonFocus(Style)Stylewidget.Button focused overlay
WithCheckBox(Style)Stylewidget.CheckBox base
WithCheckBoxFocus(Style)Stylewidget.CheckBox focused overlay
WithHeader(Style)StyleCanvas header region
WithFooter(Style)StyleCanvas footer / status bar
WithFocusBorder(Color)ColorBorder colour when a descendant is focused
WithRoundedCorner(bool)boolButton and Border use arc corners (╭─╮/╰─╯); incompatible styles silently ignored
WithDialog(Style)Stylewidget.Dialog panel
WithDialogTitle(Style)StyleTitle text inside a dialog
WithScrim(Style)StyleFull-screen backdrop behind dialogs
WithTag(Style)Stylewidget.Label chip badges
WithNotificationInfo(Style)StyleInfo notification banner
WithNotificationSuccess(Style)StyleSuccess notification banner
WithNotificationWarning(Style)StyleWarning notification banner
WithNotificationError(Style)StyleError notification banner
WithFocusBorder takes a Color, not a Style

FocusBorder is a plain Color field (used as a BorderFG override inside layout.Border), so its builder accepts a Color directly rather than a Style. All other tokens accept a Style.