tinywasm/dom is a minimalist, dependency-free wrapper over the browser DOM, optimized for TinyGo/WASM. It provides a Go-native, type-safe API for building UIs without exposing syscall/js.
- Isomorphic Core: Same structs compile for server (
!wasm) and client (wasm). - No Virtual DOM: Uses direct
.Update()calls instead of React-style VDOM diffing. Less memory, faster in WASM. - DOM-Only Layer: Provides the
Elementstruct, lifecycle interfaces, and direct DOM manipulation. HTML element builders live intinywasm/html, SVGs intinywasm/svg, and images intinywasm/image. - Zero StdLib: Uses
github.com/tinywasm/fmtinstead offmt,strings,errorsto reduce WASM size. - Slices over Maps: Attributes and events use
[]fmt.KeyValueinstead ofmap[string]stringbecause maps are extremely heavy in TinyGo.
There are three primary layers/interfaces:
- Global
domAPI:Render(parentID, comp),Append(parentID, comp),Update(comp). ComponentInterface:GetID(),SetID(id),String(),Children().ReferenceInterface: Represents a live DOM node. Read:GetAttr,Value,Checked. Mutation:SetValue,SetAttr,RemoveAttr,SetText. Interaction:On,Focus.
Render(parentID, comp) sets parent.innerHTML = html, replacing ALL existing children of the
target element. Using "body" as the mount point destroys the SVG sprite injected inline by
tinywasm/assetmin, breaking all <use href="#icon-id"> references.
The tinywasm/assetmin HTML template already injects <div id="app"></div> before the <script>
tag. Always mount the root component there:
// ✅ CORRECT — sprite SVG stays intact in <body> alongside <div id="app">
Render("app", &App{})
// ❌ WRONG — overwrites body.innerHTML, removes the SVG sprite
Render("body", &App{})| Concern | Package |
|---|---|
| HTML element builders | tinywasm/html |
| SVG builders + sprite | tinywasm/svg |
| Image builders | tinywasm/image |
| DOM manipulation, Element type, interfaces | tinywasm/dom (this package) |
Elements are constructed declaratively using builders from sibling packages:
import (
. "github.com/tinywasm/html"
. "github.com/tinywasm/dom"
)
Div(
H1("Welcome"),
P("This is a minimalist UI."),
Div(
Strong("Ready to start?"),
).Class("header-box"),
Button("Get Started").Class("primary").On("click", func(e Event) {
Log("Button clicked!")
}),
).Class("container")A component is a Go struct that embeds dom.Element as a value (never as a pointer) and implements Render() *dom.Element.
// ✅ CORRECT — value embed: 1 allocation, no nil-panic risk, better GC in TinyGo.
type Counter struct {
dom.Element
count int
}
// ❌ WRONG — pointer embed: 2 allocations, nil-panic risk, heavier GC pressure.
// type Counter struct { *dom.Element; count int }
func (c *Counter) Render() *dom.Element {
return html.Div(
html.Span("Count: ", c.count).Class("count"),
html.Button("Increment").On("click", func(e dom.Event) {
c.count++
c.Update() // Triggers direct DOM replacement for this component only
}),
).Class("counter")
}The canonical way to build components is to describe the entire UI and its behavior inside Render().
- Events in Render: Attach event listeners directly to elements using
.On(eventType, handler). The framework handles re-wiring automatically duringUpdate(). - Type-safe Pairing: Use
.For(other *Element)for<label for>pairing instead of hardcoded strings. It auto-generates IDs lazily. - Closures for Lists: Use Go closures to capture state (like loop variables) for dynamic lists.
func (c *MyComponent) Render() *dom.Element {
toggle := html.Input("checkbox").Class("toggle")
// Declarative wiring:
header := html.Label().
For(toggle). // Type-safe pairing
Text("Click me").
On("click", c.onHeaderClick) // Method reference
list := html.Div()
for _, item := range c.Items {
item := item // Capture for closure
list.Add(html.Div(item.Name).On("click", func(e dom.Event) {
c.SelectItem(item) // Closure capture
}))
}
return html.Div(toggle, header, list)
}Avoid using OnMount() for internal event wiring. OnMount() should be reserved for third-party JS integration or measuring DOM geometry.
**Why value embed?** TinyGo has a simple GC — fewer heap objects means fewer pauses. Value embedding keeps the struct and its `Element` identity in a single allocation with better cache locality.
### Component Lifecycle (WASM only)
If a component implements `Mountable`, `OnMount()` is called after injection.
```go
//go:build wasm
func (c *Counter) OnMount() { dom.Log("Mounted ID:", c.GetID()) }
Recursive Lifecycle: If a component has child components, it MUST implement Children() []dom.Component so the framework knows to trigger the child's OnMount.
To bundle styles/icons, implement these interfaces:
CSSProvider:RenderCSS() any(Expected to return*css.Stylesheetfor SSR)
The dom.Event interface provides safe access to the JS Event without syscall/js:
PreventDefault(),StopPropagation()TargetValue() string(Extremely useful fordom.Input("text")and<select>)TargetID() string
Important
dom.Input should be used only for basic layout elements (like a toggle checkbox or a simple search box). For any input that requires validation, labels, or form state management, you MUST use github.com/tinywasm/form/input.
The library handles self-closing tags correctly for:
Input(type),Img(src, alt),Br(),Hr()(when using builders fromtinywasm/htmlortinywasm/image). These return elements with thevoidflag set, preventing the rendering of a closing tag.
dom_wasm.go&element_wasm.go: Implementation usingsyscall/js.dom_backend.go&dom_stub.go(!wasm): No-op / server-side logic for compilation safety.- WASM Memory Safety:
Unmountautomatically releases all savedjs.FuncOfevent listeners.
The dom package provides a type-safe wrapper for the browser's localStorage with built-in quota management.
LocalStorageAvailable() bool: Checks if storage is accessible (handles iframe sandboxes and private modes).LocalStorageGet(key) (string, error): Retrieves a value. Returns("", nil)if the key is absent.LocalStorageSet(key, value) error: Persists a value. Enforces a 64KB per-value limit and a 4MB total budget to prevent crashes.LocalStorageDel(key) error: Removes a specific key.LocalStorageClear() error: Wipes all storage for the origin.
Note
Quota tracking is done in-memory for performance. It assumes dom is the only writer for the origin during the session.
Used to manipulate attributes on document.documentElement (the <html> tag), which is typically used for theme switching or language settings.
SetDocumentAttr(attr, value string): Sets an attribute. Passing""as value removes the attribute.GetDocumentAttr(attr string) string: Reads an attribute. Returns""if absent.
On the backend (!wasm), these are no-ops and return "", ensuring SSR safety and consistency with GetHash().
dom/ssr.go ships the default :root { … } theme of the framework via a single static function:
//go:build !wasm
package dom
import (
"github.com/tinywasm/css"
_ "embed"
)
//go:embed theme.css
var rootCSS string
func RootCSS() *css.Stylesheet { return css.New(css.Raw(rootCSS)) }theme.css is the single source of truth for the default tokens — colors, spacing, layout heights, dark-mode media query. There is no CssVars struct, no DefaultCssVars() constructor, no programmatic builder; the theme is plain CSS.
dom does not import assetmin. The contract is the RootCSSProvider interface and the free function RootCSS. tinywasm/assetmin discovers it via AST extraction during LoadSSRModules() and routes the result to the open slot of <head>.
Apps override the default by exposing their own RootCSS() from the project root's ssr.go. The single-override rule lives in assetmin (root project wins, dom is fallback, third-party modules are ignored with a warning). See assetmin/docs/SSR.md.
RootCSS()(free function inssr.go) → document-level:roottokens, single winner. Returnsany(expected*css.Stylesheet).CSSProvider.RenderCSS()(component method) → per-component scoped styles, accumulate normally. Returnsany(expected*css.Stylesheet).
These are intentionally separate: theme tokens are global and must not stack, while component styles are local and naturally compose.
dom.Get(id) returns a Reference — a live handle to a DOM node. Use its mutation methods to update the element in-place without re-rendering.
Important
dom.Render(parentID, comp) calls cleanupChildren() before writing new innerHTML, which destroys all event listeners registered via ref.On(). Always prefer in-place mutation over re-rendering when you only need to change a value, attribute, or text.
| Method | JS equivalent | Use case |
|---|---|---|
ref.SetValue(v string) |
element.value = v |
Reset input / textarea / select |
ref.SetAttr(key, value string) |
element.setAttribute(key, value) |
Add/set attribute. Pass "" for boolean attrs ("disabled") |
ref.RemoveAttr(key string) |
element.removeAttribute(key) |
Remove attribute |
ref.SetText(text string) |
element.textContent = text |
Update visible text safely (no HTML parsing — XSS-safe) |
ref, _ := dom.Get("submit-btn")
// Show loading (in-place — listener survives)
ref.SetAttr("disabled", "")
ref.SetText("Enviando…")
// Restore (in-place — listener still alive)
ref.RemoveAttr("disabled")
ref.SetText("Enviar")SetText maps to element.textContent, which treats the string as plain text — safe for user-supplied content. innerHTML interprets HTML and would require the caller to sanitize input. If controlled HTML injection is ever needed, a separate SetInnerHTML should be added with explicit XSS risk documentation.
On !wasm builds, all mutation methods are no-ops in elementStub. This is intentional — SSR never holds live DOM handles.