A Go UI framework that renders the same component tree as a terminal UI (TUI) or as semantic HTML in the browser via WebAssembly. One codebase, two build targets, zero canvas.
make run-counter # run counter example in terminal
make serve-counter # build + serve counter in browser (auto-picks a free port)
make tui-counter # run counter in terminal AND serve it in the browser
wui follows the Elm Architecture: your application defines a Model (state), an Update function (events → new state), and a View function (state → element tree). wui drives the loop on the appropriate platform.
┌──────────────────────────────────────────────────────────┐
│ Your application (shared) │
│ │
│ Model ──Update(Msg)──► (Model, Cmd) ──► View() ──► │
│ Element │
└──────────────────────┬───────────────────────┬──────────┘
│ │
┌────────────▼──────┐ ┌────────────▼──────────┐
│ TUI Renderer │ │ HTML Renderer │
│ (bubbletea + │ │ (syscall/js DOM) │
│ lipgloss) │ │ │
│ │ │ <button>, <input>, │
│ [ Increment ] │ │ <form>, <table>, <a> │
└───────────────────┘ └────────────────────────┘
The HTML renderer maps every element to the correct semantic HTML tag — not canvas, not <div> soup. This means native browser behaviours work out of the box: tab focus, form submission, scroll, accessibility, link navigation.
By default the WASM build also injects a small terminal-look stylesheet (monospace type, dark background, [ Button ] chrome, [x] checkboxes, - list markers, bordered tables, preserved whitespace so column-aligned text lines up like in a terminal) so the browser rendering visually matches the TUI. Disable it with wui.WithoutBaseCSS() if the host page brings its own styles.
| wui element | HTML output | Native behaviour |
|---|---|---|
Text |
<span> |
— |
Box(Row) |
<div style="display:flex;flex-direction:row"> |
— |
Box(Column) |
<div style="display:flex;flex-direction:column"> |
— |
Button |
<button> |
focusable, keyboard-activatable |
Input |
<input type="text/password"> |
native caret, IME, autofill |
Checkbox |
<label><input type="checkbox"> |
focusable, Space toggles, label click |
Card |
<fieldset><legend> |
— |
Form |
<form> |
submit on Enter, preventDefault wired |
List(false) |
<ul><li>…</li></ul> |
— |
List(true) |
<ol><li>…</li></ul> |
— |
Scroll |
<div style="overflow:auto"> |
native scroll |
Link |
<a href="…"> |
right-click, open-in-new-tab, history |
Table |
<table><thead><tbody>… |
— |
TextArea |
<textarea> |
native caret, multi-line editing |
Select |
<select><option> |
native dropdown, keyboard picking |
Progress |
<progress> |
native semantics, screen-reader value |
Spinner |
<span> |
— |
Divider |
<hr> / labelled <div> |
— |
Spacer / Flex |
<span style="flex:…"> |
— |
Empty |
(nothing) | — |
go get github.com/suprbdev/wuiRequires Go 1.24+. No CGO.
make help # list all targets
make build # build wui package (TUI)
make build-wasm # build wui package (WASM)
make vet # go vet both TUI and WASM targets
make test # run tests
make run-NAME # run example NAME in terminal
make tui-NAME # run example NAME in terminal + serve its web build (auto port)
make wasm-NAME # build example NAME → example/NAME/web/
make serve-NAME # build + serve example NAME in the browser (auto port)
make clean # remove built WASM binaries
NAME is any of the examples: counter, form, todo, timer, dashboard. Both serving targets auto-pick the first free port in 8765–8864 (loopback only); pin one with make serve-counter PORT=9000 (binds all interfaces).
package main
import (
"fmt"
"github.com/suprbdev/wui"
)
// 1. Define your state.
type model struct{ count int }
// 2. Define your messages.
type incrementMsg struct{}
// 3. Implement wui.Model.
func (m model) Init() wui.Cmd { return nil }
func (m model) Update(msg wui.Msg) (wui.Model, wui.Cmd) {
if _, ok := msg.(incrementMsg); ok {
m.count++
}
return m, nil
}
func (m model) View() wui.Element {
return wui.Box(wui.Column,
wui.Text(fmt.Sprintf("Count: %d", m.count)),
wui.Button("Increment", func() wui.Msg { return incrementMsg{} }),
)
}
func main() {
wui.NewProgram(model{}).Run()
}TUI — make run-counter
Browser — make serve-counter, then open the printed URL
Renders as:
<div style="display:flex;flex-direction:column;…">
<span>Count: 0</span>
<button>Increment</button>
</div>Your application state. Must be a value type (struct). wui.Model requires three methods:
type Model interface {
Init() Cmd // runs once on startup; return nil or a Cmd
Update(Msg) (Model, Cmd) // pure: old state + message → new state + optional Cmd
View() Element // pure: state → element tree
}Init and Update must be pure — no side effects. Side effects (HTTP calls, timers, file I/O) belong in a Cmd.
Any Go value. Define your own types:
type tickMsg time.Time
type loadedMsg struct{ data []Item }
type errMsg struct{ err error }wui also emits built-in messages your Update can handle:
| Type | When |
|---|---|
wui.KeyMsg{Key, Rune} |
Key press on both platforms (key names: "enter", "ctrl+c", "tab", "backspace", "up", "down", etc.). In the browser, keys aimed at a focused input/textarea/select stay with that element and are not dispatched |
wui.ResizeMsg{Width, Height} |
Terminal or window resize |
wui.InputMsg{ID, Value} |
Input value changed with no OnChange callback set |
wui.ToggleMsg{ID, Checked} |
Checkbox toggled with no OnToggle callback set |
wui.SubmitMsg{FormValues} |
Form submitted with no OnSubmit callback set |
wui.ClickMsg{TargetID} |
Link clicked |
wui.NavigateMsg{Path} |
Browser URL hash names a path — initial load or back/forward (WASM only; see wui.Pather) |
A func() Msg run asynchronously. Use it for side effects:
func fetchData() wui.Cmd {
return func() wui.Msg {
resp, err := http.Get("https://api.example.com/data")
if err != nil {
return errMsg{err}
}
// decode resp...
return loadedMsg{data: items}
}
}
func (m model) Init() wui.Cmd {
return fetchData()
}Return a Cmd from Init or Update. wui runs it in a goroutine and dispatches the resulting Msg back through Update.
wui.Text("Hello, world!")
// With style:
wui.Text("Warning", wui.WithTextStyle(wui.Style{FG: "#ff0000", Bold: true}))Renders as <span> in HTML.
Flex container — wui.Row or wui.Column:
wui.Box(wui.Row,
wui.Text("Left"),
wui.Text("Right"),
)
wui.Box(wui.Column,
wui.Text("Top"),
wui.Text("Bottom"),
)
// With gap and style:
wui.BoxStyled(wui.Column, 8, wui.Style{Border: true},
wui.Text("Item 1"),
wui.Text("Item 2"),
)wui.Button("Save", func() wui.Msg { return saveMsg{} })
// Disabled:
wui.Button("Save", nil, wui.Disabled())
// With style:
wui.Button("Delete", onDelete, wui.WithButtonStyle(wui.Style{FG: "#ff0000"}))
// With an explicit identity (needed when several buttons share a label,
// e.g. a per-row "✕" delete button):
wui.Button("✕", onDelete, wui.WithID("del-"+itemID))TUI: rendered as [ Label ], focusable via Tab, activated via Enter or Space.
HTML: <button onclick=…>.
A button's focus identity is its WithID value when set, else its label. Buttons that share a label and have no ID share TUI focus and a DOM id — give repeated buttons IDs.
Text inputs require a stable id string — wui uses it to maintain cursor/blink state in TUI and to identify the DOM element in HTML.
wui.Input("username",
wui.WithValue(m.username),
wui.WithPlaceholder("Username"),
wui.WithOnChange(func(val string) wui.Msg {
return usernameChanged{val}
}),
)
// Password field:
wui.Input("password",
wui.WithPlaceholder("Password"),
wui.WithPassword(),
wui.WithOnSubmit(func(val string) wui.Msg {
return loginMsg{password: val}
}),
)OnChange fires on every keystroke. OnSubmit fires on Enter, on both platforms.
Input values are controlled with a twist: in-progress typing is never clobbered by re-renders, but when your app changes WithValue programmatically (e.g. clearing a field after submit), the new value is applied. This works identically in TUI and HTML.
wui.Checkbox("done-42", "Buy milk", item.Done, func(checked bool) wui.Msg {
return toggleMsg{id: 42, done: checked}
})
// Without a callback, toggling emits wui.ToggleMsg{ID, Checked}:
wui.Checkbox("opt-in", "Subscribe", m.optIn, nil)
// Disabled:
wui.Checkbox("locked", "Read-only", true, nil, wui.CheckboxDisabled())TUI: rendered as [x] Label / [ ] Label, focusable via Tab, toggled via Enter or Space.
HTML: a real <label><input type="checkbox"> — Space toggles, clicking the label toggles, screen readers announce it. The base CSS hides the native box and draws the same [x] mark as the TUI.
The id must be stable and unique per view (like Input).
A titled, bordered panel — the standard dashboard building block:
wui.Card("Weather", body)
// With style (Border is implied; Padding, Margin, Width, BorderColor apply):
wui.Card("Weather", body, wui.WithCardStyle(wui.Style{Padding: [4]int{0, 1, 0, 1}}))TUI: rounded border with the bold title embedded in the top border line:
╭ Weather ─────────╮
│ 21.3°C clear │
╰──────────────────╯
HTML: <fieldset><legend>Weather</legend>…</fieldset> — the semantic element for a titled group, styled by the base CSS to match.
Wraps inputs in a <form> (HTML) or a vertical container (TUI). The OnSubmit callback receives all input values by ID when the form is submitted.
wui.Form(
func(vals map[string]string) wui.Msg {
return loginMsg{user: vals["user"], pass: vals["pass"]}
},
wui.Input("user", wui.WithPlaceholder("Username")),
wui.Input("pass", wui.WithPlaceholder("Password"), wui.WithPassword()),
wui.Button("Log in", nil), // nil OnClick inside a Form = submit button
)Submission works the same on both platforms:
- A button with
nilOnClick inside a Form acts as a submit button — like<button type="submit">in HTML; in TUI, Enter on the focused button submits the form. - Enter in an input inside the form also submits it (unless the input has its own
OnSubmit, which then takes precedence).
// Unordered:
wui.List(false,
wui.Text("First item"),
wui.Text("Second item"),
wui.Button("Clickable item", onClick),
)
// Ordered:
wui.List(true,
wui.Text("Step one"),
wui.Text("Step two"),
)Items can be any Element, not just text. Renders as <ul>/<ol> + <li> in HTML.
wui.Scroll(
wui.Box(wui.Column, items...),
20, // max height in terminal rows, both platforms
)TUI: lipgloss.MaxHeight. HTML: overflow:auto; max-height:Nlh (an em fallback covers browsers without lh).
wui.Link("Docs", "https://pkg.go.dev/github.com/suprbdev/wui")HTML: real <a href="…"> — right-click works, opens in new tab, browser history, screen readers. TUI: underlined text, focusable via Tab.
wui.Table(
[]string{"Name", "Version", "Status"},
[][]string{
{"bubbletea", "v1.3.10", "✓"},
{"lipgloss", "v1.1.0", "✓"},
},
)TUI: lipgloss table with rounded borders. HTML: <table><thead><tbody>.
wui.TextArea("notes",
wui.WithAreaValue(m.notes),
wui.WithAreaPlaceholder("what changed?"),
wui.WithAreaRows(4),
wui.WithAreaOnChange(func(v string) wui.Msg { return notesMsg{v} }),
)Multi-line input. Enter inserts a newline rather than submitting (matching <textarea>), so a form containing one is submitted from its submit button. Like Input, in-progress typing survives re-renders and is only overwritten when the app changes the value itself.
wui.Select("env",
[]wui.SelectOption{
wui.Opt("dev", "Development"),
wui.Opt("prd", "Production"),
},
m.env,
func(v string) wui.Msg { return envMsg{v} },
)HTML: a native <select>. TUI: a focusable < Development > field that cycles with ←/→ (or Space/Enter). wui.Options("a", "b") builds options whose labels are their values.
wui.Progress(0.42, wui.WithProgressWidth(30), wui.WithProgressLabel())
wui.Spinner(m.frame, wui.WithSpinnerLabel("deploying"))Progress clamps to 0–1. Spinner is deliberately stateless — the app owns the animation clock, so both renderers stay pure and the two platforms animate identically:
case tickMsg:
m.frame++
return m, wui.Tick(100*time.Millisecond, func() wui.Msg { return tickMsg{} })wui.Divider("Section") // ── Section ────── / labelled <hr>
wui.Spacer(2) // 2 cells (Row) or 2 lines (Column)
wui.Flex() // stretches, pushing siblings apart
wui.Empty() // renders nothingFlex is the idiom for pinning a child to the far end of a row:
wui.BoxStyled(wui.Row, 0, wui.NewStyle().W(60),
wui.Text("Deploy dashboard"),
wui.Flex(),
wui.Text(status), // hard right
)Views stay single expressions instead of being assembled imperatively:
wui.If(m.err != "", wui.Text(m.err)) // or Empty()
wui.IfElse(m.loading, spinner, results)
wui.When(m.open, func() wui.Element { … }) // build called only if true
wui.List(false, wui.Map(m.items, func(i int, s string) wui.Element {
return wui.Text(s)
})...)Group, HStack, VStack, and Center cover the common layout shorthands.
Bindings are declared once and drive three things: dispatch in Update, generated help text, and the status bar's hints — so help can never drift from what the keys actually do.
var (
keyRun = wui.NewBinding("run", wui.Keys("r"), wui.Help("r", "run job"))
keyQuit = wui.NewBinding("quit", wui.Keys("q", "ctrl+c"), wui.Help("q", "quit"))
)
func (m model) keyMap() wui.KeyMap {
return wui.NewKeyMap(
// Disabled bindings match nothing and vanish from help.
keyRun.SetEnabled(!m.running),
keyQuit,
)
}
func (m model) Update(msg wui.Msg) (wui.Model, wui.Cmd) {
switch v := msg.(type) {
case wui.KeyMsg:
name, ok := m.keyMap().Match(v)
if !ok {
return m, nil
}
switch name {
case "run":
return m, startJob()
case "quit":
return m, wui.Quit()
}
}
return m, nil
}Help renders from the same map:
m.keyMap().ShortHelp() // "r run job • q quit"
m.keyMap().HelpView(wui.Column) // an Element, one row per bindingRegister the map with wui.WithKeyMap(km) and the status bar shows the shortcuts automatically. wui never dispatches bindings for you — Update stays the single place state changes.
For table-driven dispatch, pair bindings with actions instead:
handlers := []wui.Handler{
wui.Bind(keyRun, func() wui.Msg { return startMsg{} }),
wui.Bind(keyQuit, func() wui.Msg { return wui.QuitMsg{} }),
}
if out, ok := wui.Dispatch(v, handlers...); ok {
return m, wui.Send(out)
}Both platforms draw a one-line status strip: the bottom row in the TUI, a fixed bottom strip in the browser.
By default it shows the web URL (when WithWebServer is active) and the key map's short help. Supply your own with WithStatus:
wui.NewProgram(m,
wui.WithKeyMap(m.keyMap()),
wui.WithStatus(func(cur wui.Model) wui.Status {
d := cur.(model)
return wui.NewStatus().
Left("env:" + d.env).
Right(d.keyMap().ShortHelp())
}),
)Left- and right-aligned segments are pushed apart; when the terminal is too narrow for both, the right side is dropped before the left is truncated. The web link is prepended to whatever you return, so adding your own segments never costs you the link to your own web build.
Return wui.NewStatus().Hide() to suppress the bar for a frame, or pass wui.WithoutStatusBar() to turn it off entirely.
Sometimes the same idea needs different wording — the TUI has a focus ring, the browser has clicks. Current(), IsTUI(), and IsWeb() are build-time constants, so branches cost nothing at runtime.
// Different wording, same instruction:
wui.Text(wui.PlatformText("press q to quit", "close this tab to quit"))
// Different elements:
wui.OnPlatform(
wui.Text("Tab moves focus • Enter activates"),
wui.Text("Click anything, or Tab through the page"),
)
// Only on one platform:
wui.TUIOnly(wui.Text("Ctrl+C also works"))
wui.WebOnly(wui.Link("Docs", "/docs"))
// Any value, not just Elements:
width := wui.PlatformValue(80, 1200)wui.Style is a platform-agnostic style struct:
type Style struct {
FG, BG Color // hex "#ff0000", CSS color name "red", or ANSI index "9"
Bold bool
Italic bool
Underline bool
Faint bool
Strike bool
Padding [4]int // top, right, bottom, left — cells
Margin [4]int // top, right, bottom, left — cells
Width int // 0 = auto — cells
Height int // 0 = auto — cells
Border bool
BorderColor Color
Align Align // text alignment within Width
}Struct literals get verbose fast, so Style also composes. Every method returns a copy:
wui.NewStyle().SetBold(true).Fg("6").Pad(1, 2) // CSS-style shorthand
wui.NewStyle().W(40).Aligned(wui.AlignCenter)
wui.NewStyle().Bordered("#3a3d46")Pad and Mar take 1–4 values with CSS semantics (all / vertical-horizontal / top-horizontal-bottom / top-right-bottom-left).
Merge layers a variant over a base — useful for theme-ish reuse:
base := wui.NewStyle().Pad(0, 1)
danger := base.Merge(wui.Style{FG: "9", Bold: true})Color values:
- Hex strings (
"#ff0000") work on both platforms. - Named CSS colors (
"red","cornflowerblue") work on both platforms. - ANSI indices
"0"–"15"are translated to hex for HTML, passed to lipgloss as-is for TUI.
Units: all sizing values (Width, Height, Padding, Margin, Box gap) are terminal cells. The HTML renderer translates them to the closest CSS analogues — ch horizontally and lh (line height, with an em fallback) vertically — so the same numbers produce visually similar layouts on both platforms.
| Key | Action |
|---|---|
Tab |
Focus next focusable element |
Shift+Tab |
Focus previous focusable element |
Enter / Space |
Activate focused button, link, or checkbox; Enter submits a focused input (its OnSubmit, else the enclosing form) |
← / → |
Cycle a focused Select through its options (also ↑/↓) |
Esc |
Blur the focused element; the key is also forwarded to Update |
Ctrl+C |
Quit (intercepted by wui, not forwarded to Update) |
A focused element only consumes the keys it actually uses (text editing for inputs, Enter/Space for buttons and checkboxes, arrows for selects); everything else falls through to Update, so app-level shortcuts keep working while something is focused. Esc always blurs first — an input can never trap the keyboard.
Mouse clicks are also wired in the TUI: clicking a button, link, checkbox, or input activates it and moves keyboard focus to it, like the browser.
Focusable elements are collected in tree order: text inputs, text areas, selects, checkboxes, buttons, and links. Button focus keys derive from WithID when set, else from the label — give repeated buttons IDs.
Your Update receives wui.KeyMsg for any key that isn't consumed by focus management or input editing — in the TUI and in the browser alike (WASM builds listen on document and skip keys aimed at an editable element; alt/meta chords and unmapped named keys stay with the browser, and space, backspace, ', /, and tab have their page defaults suppressed so they behave like the TUI). Use it for global shortcuts:
case wui.KeyMsg:
if v.Key == "q" {
// return a Cmd that signals quit, or transition to a done state
}func (m model) Init() wui.Cmd {
return wui.Tick(time.Second, func() wui.Msg { return tickMsg{} })
}
func (m model) Update(msg wui.Msg) (wui.Model, wui.Cmd) {
switch msg.(type) {
case tickMsg:
m.elapsed++
// Reschedule.
return m, wui.Tick(time.Second, func() wui.Msg { return tickMsg{} })
}
return m, nil
}Each Cmd runs in its own goroutine. The returned Msg is dispatched back through Update on the main loop.
| Helper | Purpose |
|---|---|
Tick(d, build) |
deliver a Msg once, after d elapses |
Every(d, build) |
same, but aligned to the wall clock so repeated ticks don't drift |
Batch(cmds…) |
run cmds concurrently; every resulting Msg is delivered |
Sequence(cmds…) |
run cmds in order, when a later one depends on an earlier |
Send(msg) |
wrap a value you already have as a Cmd |
Do(f) |
run f in the background (the plain-function form) |
Quit() |
end the program — restores the terminal properly, unlike os.Exit |
Use Every rather than Tick for anything displaying a real clock: Tick drifts by however long Update takes, Every re-aligns each period.
Batch and Sequence deliver their results as a BatchMsg, which the runtime unpacks and feeds through Update one Msg at a time — so Update never has to know a batch happened, and batches nest safely.
| Option | Effect |
|---|---|
WithWebServer(addr, dir) |
Serve a WASM build of the same app while the TUI runs, and link to it from the status bar. No effect in WASM builds, so it is safe to pass unconditionally |
WithKeyMap(km) |
Register the app's key map so the status bar shows its shortcuts; also readable via Program.KeyMap() |
WithStatus(f) |
Supply the status line content (see Status line) |
WithoutStatusBar() |
Suppress the status line on both platforms |
WithTitle(s) |
Set document.title (WASM) / the terminal window title (TUI) |
WithoutBaseCSS() |
Skip wui's default stylesheet in WASM builds |
WithoutAltScreen() |
Run the TUI inline so the final frame stays in scrollback after exit |
WithoutMouse() |
Disable TUI mouse tracking, leaving click and text selection to the terminal |
A TUI program can serve the WASM build of the same app over HTTP while it runs:
wui.NewProgram(model{}, wui.WithWebServer(":8765", "example/counter/web")).Run()
// Or pass "" to pick a port automatically: binds loopback only, on the
// first free port in 8765-8864 (OS-assigned as a last resort).
wui.NewProgram(model{}, wui.WithWebServer("", "example/counter/web")).Run()The examples expose this as a -serve flag via the wui.ServeFlag helper:
make tui-counter # builds the WASM bundle, then:
go run ./example/counter -serve # auto-pick a free port (loopback only)
go run ./example/counter -serve=:8765 # explicit addressNote the = in the explicit form — because bare -serve is allowed, the flag package treats it as boolean-style, so -serve :8765 would not parse. In your own main:
serve := wui.ServeFlag("serve", "also serve the web build")
flag.Parse()
var opts []wui.Option
if serve.Enabled {
opts = append(opts, wui.WithWebServer(serve.Addr, "path/to/web"))
}While serving, the TUI always shows a status bar pinned to the bottom of the screen with the URL of the equivalent web page:
web ⇒ http://localhost:8765/
WithWebServer is a no-op in WASM builds, so shared main code can pass it unconditionally.
If your model implements wui.Pather, both platforms agree on where in the app you are:
func (m model) Path() string {
if m.state == stateResult {
return "/result"
}
return "/"
}- TUI: the status bar link includes the path —
http://localhost:8765/#/result. - Browser:
location.hashis kept in sync withPath()after every update, and awui.NavigateMsg{Path}is dispatched on initial load and on hash changes (back/forward), so deep links and history work:
case wui.NavigateMsg:
if v.Path == "/result" && m.message != "" {
m.state = stateResult
} else {
m.state = stateForm
}make serve-counter # builds WASM + copies wasm_exec.js + serves on a free port
make serve-formOr manually:
GOOS=js GOARCH=wasm go build -o web/main.wasm .
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" web/<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
.then(r => go.run(r.instance));
</script>
</body>
</html>wui mounts a <div id="wui-root"> into document.body automatically.
make serve-counter and make serve-form handle the WASM MIME type automatically. For production or custom setups, ensure the server sets Content-Type: application/wasm for .wasm files — most servers (Caddy, nginx, Go's net/http) do this by default. For ad-hoc local serving:
# npx serve web
# caddy file-server --root webAll bubbletea/lipgloss/bubbles code is behind //go:build !js. All syscall/js DOM code is behind //go:build js. Standard Go build tags select the right renderer automatically — no manual configuration needed.
wui/
├── element.go Element interface + all concrete types + constructors
├── style.go Style struct (shared, no platform deps)
├── msg.go Msg, Cmd, and built-in message types
├── program.go Model interface + Program (NewProgram / Run)
├── program_tui.go //go:build !js — bubbletea adapter + key routing
├── program_wasm.go //go:build js — DOM event loop + dispatch
├── render_tui.go //go:build !js — element tree → lipgloss string
├── render_wasm.go //go:build js — element tree → DOM nodes
├── focus_tui.go //go:build !js — Tab ring (index-based)
├── focus_wasm.go //go:build js — Tab ring (delegates to browser)
├── key.go Binding / KeyMap / help generation (shared)
├── cmd.go Cmd helpers: Batch, Sequence, Tick, Every, Quit
├── status.go Status line model + shared line layout
├── platform.go Platform detection + conditional helpers (shared)
├── platform_tui.go //go:build !js — currentPlatform = PlatformTUI
└── platform_wasm.go //go:build js — currentPlatform = PlatformWeb
The element.go, style.go, msg.go, program.go, key.go, cmd.go, status.go, and platform.go files have no build tags and no platform dependencies — they are the shared contract between your app and both renderers.
TUI: bubbletea calls View() on every state change and diffs the output string to minimise redraws.
HTML: v1 does a full replace of #wui-root on every state change. Before clearing the DOM, wui saves:
document.activeElement.id— restored after the new tree is built, preserving keyboard focus.- All
<input>values by id — restored after the new tree, preventing in-progress text from being wiped by unrelated state changes.
This is efficient enough for typical app trees. VDOM diffing is a future enhancement.
Every example runs three ways:
make run-NAME # terminal
make serve-NAME # browser, auto-picked free port (printed on start)
make tui-NAME # terminal + web server + status bar linkMinimal example: increment/decrement/reset buttons, styled title, row layout.
Two text inputs, validation, form submission via submit button or Enter, result view with a data table. Implements wui.Pather — the result screen lives at #/result in the browser and the TUI status bar links to it.
TUI interaction:
Tabto cycle between Name input, Email input, Submit buttonEnteron the Submit button (or in an input) to submitqor Back button to return to the form
Add items via input + submit button (or Enter), remove items with per-item buttons, scrollable list, programmatic input clearing after submit.
Exercises the framework features rather than a domain: declarative key bindings with generated help (including bindings that disable themselves mid-run), a custom status line, platform-conditional wording, and the layout/feedback elements — Divider, Flex, Progress, Spinner, Select, TextArea.
A stopwatch: Cmd-driven self-rescheduling ticks, start/stop/reset, bordered display.
| Package | Used by | Purpose |
|---|---|---|
github.com/charmbracelet/bubbletea |
TUI only | Event loop, alt-screen, key input |
github.com/charmbracelet/lipgloss |
TUI only | Layout, styling, borders, tables |
github.com/charmbracelet/bubbles |
TUI only | Text input widget (cursor, blink, echo modes) |
syscall/js |
WASM only | DOM manipulation (Go standard library) |
The WASM binary includes none of the charmbracelet packages. The TUI binary includes no syscall/js code. Both are enforced by //go:build constraints.
- No VDOM diffing — HTML renderer does a full tree replace on each update. Works well for most apps; large frequently-updating trees may flicker.
- Button focus keys derive from labels by default — two buttons with the same label and no
WithIDin one view share TUI focus; give repeated buttons IDs. - No theme system — style is applied per-element inline (
Style.Mergehelps compose variants); the WASM base stylesheet (seeWithoutBaseCSS) is the only global styling hook. - TextArea editing is basic in the TUI — typing, Enter, and Backspace append and delete at the end of the buffer; there is no cursor movement within the text. The browser build uses a native
<textarea>with full editing. - ANSI color fidelity — ANSI indices 0–15 map to fixed hex values in HTML; exact colours depend on the terminal's colour scheme in TUI.
MIT