Theming
Paired role tokens, color scales, schemes — resolved once at NewTheme.
Palette is the single source of truth for color: named semantic tokens
instead of raw hex scattered through views. Tokens come in pairs — a fill and its
readable ink (BrandSolid+BrandContrast,
Danger+DangerContrast, each status ink with its pastel background) —
so a component that paints a surface always knows what stays readable on it. Dark mode is just
another palette: see Dark mode.
Tokens
The standard semantic vocabulary: background ladder (Bg,
BgSubtle, BgMuted, BgEmphasized, BgPanel,
BgInverted), borders (BorderSubtle → BorderEmphasized),
a foreground ladder (Fg → FgDisabled, plus FgInverted),
the brand slots (BrandSolid, BrandSubtle, BrandFg,
BrandEmphasized, BrandContrast), four status pairs, the dialog
Overlay and the keyboard FocusRing. Space is the 8pt
spacing scale; Radius the corner scale.
th := lotusui.NewTheme()
// Semantic tokens, never raw hex in views:
bg := th.Palette.Bg // the canvas
ink := th.Palette.Fg // primary text
// Scales:
gap := th.Space.MD // 16dp
r := th.Radius.MD // 10dp
If you come from the web
The tokens are a direct transposition of the semantic-token vocabulary web
component libraries use — dotted paths become CamelCase, because in Go a field can't be both a
color and a namespace (fg can't simultaneously be Fg and hold
Fg.Muted; flattening keeps the most-used token the shortest).
| Web token | lotusui |
|---|---|
bg, bg.subtle, bg.muted, bg.emphasized, bg.panel, bg.inverted | Bg, BgSubtle, BgMuted, BgEmphasized, BgPanel, BgInverted |
fg, fg.muted, fg.subtle, fg.inverted | Fg, FgMuted, FgSubtle, FgInverted (+ FgDisabled for placeholders) |
border, border.muted, border.subtle, border.emphasized | Border, BorderMuted, BorderSubtle, BorderEmphasized |
colorPalette.solid / .subtle / .fg / .emphasized / .contrast | BrandSolid, BrandSubtle, BrandFg, BrandEmphasized, BrandContrast |
fg.success / bg.success (and warning, info, error) | Success/SuccessBg, Warning/WarningBg, Info/InfoBg, Danger/DangerBg/DangerContrast |
| focus ring / dialog overlay | FocusRing / Overlay |
Schemes — variants carry their role
A Scheme is a semantic color scheme: how one color role renders
across a component's variants — including its interaction steps (base, hover, active). The
palette exposes three — Accent() (the brand), Neutral() (the greys),
and DangerScheme() (destructive actions only) — and each VARIANT resolves its own:
Default→Accent, Secondary/Outline/Ghost→Neutral, Destructive→Danger. Every scheme field is a
palette token or scale step — nothing hard-coded — so a custom palette propagates to every
variant and every interaction state.
accent := th.Palette.Accent()
// accent.Solid / SolidHover / SolidActive, OnSolid,
// accent.Subtle / SubtleHover / SubtleActive, OnSubtle, Outline
Per-instance color — the precedence ladder
Three layers, most-derived wins. Default: the variant's role scheme from the
theme. Per instance: Color (any ColorScale) re-colors the variant —
one anchor in, the whole .500/.600/.700 interaction ladder out, so a custom color can never
break its own hover and pressed states. Full control: Scheme overrides every slot
by hand.
lotusui.Button(th, &b, "Save", lotusui.ButtonOpts{}) // theme role
lotusui.Button(th, &b, "Go", lotusui.ButtonOpts{Color: lotusui.Teal}) // scale — ladder derived
soft := lotusui.Teal.SoftScheme()
lotusui.Button(th, &b, "Go", lotusui.ButtonOpts{Scheme: &soft}) // manual slots
Color scales
A ColorScale is one hue graded 50…900 — 50 the faintest tint, 500
the anchor, 900 the darkest ink. Ten stock scales ship (Gray through
Pink), and ScaleFrom grades a complete scale from any single color —
one brand color in, ten usable steps out. A scale becomes a button scheme via
.Scheme() (saturated, .500/.600/.700 interaction steps) or
.SoftScheme() (pastel).
Your brand's scale needs no setup at all: th.BrandScale is graded automatically
from the palette's BrandFg when the theme is built — define one custom brand color
and the whole 50…900 ladder is already there.
// Automatic — derived from your palette at NewTheme:
tint := th.BrandScale.C50
ink := th.BrandScale.C700
scheme := th.BrandScale.Scheme()
// Or grade any color on the spot:
mint := lotusui.ScaleFrom(color.NRGBA{R: 0x31, G: 0x97, B: 0x95, A: 0xFF})
Multiple themes
A theme is plain data — a Palette is ~200 bytes — so an app can compile in as many looks as it wants and let its users switch: build the themes once at startup, swap a pointer on selection. No parsing, no assets, no measurable weight. The palette picker in this site's top bar is exactly that pattern live: eleven presets compiled into both the pages and the wasm demos; picking one restyles everything on the fly.
var themes = map[string]*lotusui.Theme{
"lavender": lotusui.NewTheme(),
"teal": lotusui.NewTheme(lotusui.WithPalette(tealPalette)),
"rose": lotusui.NewTheme(lotusui.WithPalette(rosePalette)),
}
th := themes[userChoice] // switching themes is a pointer swap
Theming
Customization flows through NewTheme options and resolves once, at
construction — a fully custom look has zero per-frame cost. Components read every color from
th.Palette, every corner from th.Radius, every gap from
th.Space; swap any of them and the whole library follows consistently.
WithFaces replaces the embedded DM Sans with your brand font.
The fastest start: generate a palette from your brand color —
go run github.com/ikaito-com/lotusui/cmd/lotusui theme -anchor '#319795' -o theme_gen.go
grades the anchor into a scale, maps it onto the brand tokens, and emits plain Go you own.
For the full declarative workflow, keep a theme.json
(anchor, token overrides, radius/space scales, text size) and regenerate with
-config theme.json: designers edit JSON, the build turns it into typed literals,
and the runtime never parses anything. Because this runs at build time it also
validates: every text-on-surface pair is contrast-checked (WCAG 4.5:1), and
-strict makes an unreadable palette a build failure instead of a shipped bug.
brand := lotusui.DefaultPalette
brand.BrandSolid = color.NRGBA{R: 0xD9, G: 0xF3, B: 0xE4, A: 0xFF}
brand.BrandFg = color.NRGBA{R: 0x11, G: 0x7A, B: 0x3D, A: 0xFF}
th := lotusui.NewTheme(
lotusui.WithPalette(brand),
lotusui.WithRadius(lotusui.RadiusScale{SM: 4, MD: 8, LG: 12}),
lotusui.WithTextSize(15),
)