Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

wui

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

How it works

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.

Element → HTML mapping

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)

Installation

go get github.com/suprbdev/wui

Requires Go 1.24+. No CGO.


Make targets

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).


Quick start

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()
}

TUImake run-counter

Browsermake serve-counter, then open the printed URL

Renders as:

<div style="display:flex;flex-direction:column;…">
  <span>Count: 0</span>
  <button>Increment</button>
</div>

Core concepts

Model

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.

Msg

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)

Cmd

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.


Elements

Text

wui.Text("Hello, world!")

// With style:
wui.Text("Warning", wui.WithTextStyle(wui.Style{FG: "#ff0000", Bold: true}))

Renders as <span> in HTML.

Box

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"),
)

Button

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.

Input

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.

Checkbox

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).

Card

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.

Form

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 nil OnClick 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).

List

// 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.

ScrollArea

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).

Link

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.

Table

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>.

TextArea

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.

Select

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.

Progress and Spinner

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{} })

Divider, Spacer, and Empty

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 nothing

Flex 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
)

Conditionals and lists

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.


Keyboard shortcuts

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 binding

Register 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)
}

Status line

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.


Platform-conditional content

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)

Styling

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.


Keyboard navigation (TUI)

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
    }

Commands (async work)

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.


Program options

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

Running TUI and web together

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 address

Note 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.

Equivalent paths (Pather)

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.hash is kept in sync with Path() after every update, and a wui.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
    }

WASM build

Quick start

make serve-counter   # builds WASM + copies wasm_exec.js + serves on a free port
make serve-form

Or manually:

GOOS=js GOARCH=wasm go build -o web/main.wasm .
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" web/

HTML harness

<!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.

Serving

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 web

Build constraints

All 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.


Architecture

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.

Re-render strategy

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:

  1. document.activeElement.id — restored after the new tree is built, preserving keyboard focus.
  2. 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.


Examples

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 link

Counter (example/counter/)

Minimal example: increment/decrement/reset buttons, styled title, row layout.

Contact form (example/form/)

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:

  • Tab to cycle between Name input, Email input, Submit button
  • Enter on the Submit button (or in an input) to submit
  • q or Back button to return to the form

Todo (example/todo/)

Add items via input + submit button (or Enter), remove items with per-item buttons, scrollable list, programmatic input clearing after submit.

Dashboard (example/dashboard/)

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.

Timer (example/timer/)

A stopwatch: Cmd-driven self-rescheduling ticks, start/stop/reset, bordered display.


Dependencies

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.


Limitations (v1)

  • 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 WithID in one view share TUI focus; give repeated buttons IDs.
  • No theme system — style is applied per-element inline (Style.Merge helps compose variants); the WASM base stylesheet (see WithoutBaseCSS) 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.

License

MIT

About

go tui that supports wasm output with native html elements

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages