This package should be considered experimental.
A relatively fast, zero-allocation HTML tree builder and renderer for Go.
It provides a Node type with a chaining method API
and an optional functional API that simplifies composition.
While fully capable of direct use, its primary goal is to serve as a foundation
for higher-level DSLs and component frameworks.
- Build and render HTML trees with very low overhead.
- Be a good backend for DSLs (component frameworks, templates, code generators).
- Enable composition and extensibility via functional API.
- Make performance characteristics explicit.
Non-goals:
- Being a complete web framework.
- Hiding all footguns.
Direct method chaining is the most performant way to build trees:
root := htm.Div().
ID("container").
Class("flex column").
Content(
htm.H1().
Text("Hello, World!"),
htm.Button().
OnClick("alert('clicked')").
Disabled().
Text("Click Me"),
)
defer root.Release()
_ = root.Render(os.Stdout)Mods (or modifiers) are functions with the signature func(*Node).
This is primarily about extensibility and composition.
It allows to define custom logic and higher-level abstractions in separate packages.
root := htm.Div(
htm.ID("container"),
htm.Class("flex column"),
htm.Content(
htm.H1(htm.Content(
htm.Text("Hello, World!"),
)),
htm.Button(
aria.Label("button"),
htm.OnClick("alert('clicked')")
Content(htm.Text("Click Me")),
),
))
defer root.Release()
_ = root.Render(os.Stdout)A component layer can be built on top of htm by defining constructors and modifiers in your own packages.
// package ui
func Button(mods ...htm.Mod) *htm.Node {
return htm.Button().
Class("btn").
Apply(mods)
}
func Primary() htm.Mod {
return htm.Class("btn-primary")
}
func SetFoo(v string) htm.Mod {
return func(n *htm.Node) {
n.Attr("foo", htm.String(v))
}
}
// etc.Usage:
btn := ui.Button(ui.Primary(), htm.Content(
htm.Text("Save"),
))
// or
btn := ui.Button(ui.Primary(), htm.TextContent("Save"))
// or
btn := ui.Button(ui.Primary()).Text("Save")
// or
btn := ui.Button().Text("Save").Mod(ui.Primary())
// or
btn := ui.Button(SetFoo("bar")).Text("Save").Mod(ui.Primary())
// etc.This gives "components" that can be combined into higher level building blocks.
The package allows attaching custom variables to nodes. These variables are not rendered and are used only during tree construction.
func Btn(mods ...htm.Mod) *htm.Node {
return htm.Button().
Class("htm-btn").
Apply(mods).
DefaultContent(func(n *htm.Node) {
doSomething(n)
if icon := n.GetVar("icon").StringOrZero(); icon != "" {
n.Append(IconFn(icon).Class("htm-btn-icon"))
}
if caption := n.GetVar("caption").StringOrZero(); caption != "" {
n.Append(htm.Span().Class("htm-btn-caption").Text(caption))
}
})
}
btn := Btn().Var("caption", "Save").Var("icon", "save")Slots provide a way to pass dynamic content into components.
func Btn(mods ...htm.Mod) *htm.Node {
return htm.Button().
Class("my-btn").
Apply(mods).
Postpone(func(n *htm.Node) {
n.Prepend(n.ExtractSlot("icon"))
})
}
btn := Btn().Slot("icon", mysvg.Icon("close")))Everything can be overwritten / replaced:
htm.A().Href("#").Href("#abc") // result is "#abc"
htm.Button().Class("a b c").RemoveClass("a") // result is "b c"
htm.Button().Content(Span()).Content(Div()) // result is div
htm.Button().Content(Span()).Prepend(Div()) // result is div and span
card := ui.Card().Slot("footer", htm.Div(), htm.Span())
card.Slot("footer", htm.Button()) // footer now contains only a button
card.AppendSlot("footer", htm.Div()) // footer now contains a button and a divMod functions can be used directly from types:
func (t *MyType) LocalizedLabel(n *htm.Node) { ... }
// ...
htm.Button(t.LocalizedLabel)A mod can be postponed until rendering to allow other things to happen:
htm.Div(func (n *htm.Node) {
n.Postpone(mod1, mod2)
n.Postpone(func (n *htm.Node) { ... })
})For complex operations it is often more performant to do everything inside a single mod:
htm.Div(func (n *htm.Node) {
n.Attr(...)
n.Class(...)
doThing(n)
n.DefaultContent(...)
doOtherThing(n)
// etc.
})Functions returning two values can be used directly:
func MyProp(...) (string, string) { return "my-prop", "example" }
htm.Div().Attr(MyProp())The package provides a helper to render a subtree once and cache the result.
node := htm.Div().StaticContent(func() *htm.Node {
// this runs once, the result is cached as raw bytes
return htm.Group(
htm.Span().Class("icon").Text("π"),
htm.Span().Class("label").Text("Brand"),
)
})You can also define static fragments globally:
var logo = htm.Static(func() *htm.Node {
return htm.Group(
htm.Span(htm.Class("icon"), htm.Text("π")),
htm.Span(htm.Class("label"), htm.Text("Brand")),
)
}).Own() // prevents returning the node to the pool
func RenderHeader() *htm.Node {
return htm.Div(
htm.Tag("header")
htm.Content(logo), // uses cached bytes
)
}Caching is done by using the function pointer as a key, closures are therefore executed at most once, regardless of captured variables.
To avoid allocations occurring when using any, the package provides strongly typed value helpers.
htm.Input().Value(htm.Int(42))Many attribute helpers also accept typed arguments directly:
htm.Input().TabIndex(1)Most methods accept a string by default:
n.Attr("name", "value")
n.Var("name", "value")
n.Text("hello")
htm.Attr("name", "value")
htm.Var("name", "value")
htm.Text("hello")For other values, variants are available:
n.AttrValue("name", htm.Int(-42))
n.AttrValue("name", htm.Uint(100))
n.AttrValue("name", htm.Bool(false)) // unsets attribute
n.AttrValue("name", htm.JSON(myStruct)) // rendered as escaped JSON
n.AttrValue("name", htm.Float(3.14))
n.TextValue(htm.Int(42))
htm.TextValue(htm.Int(42))The package includes a large set of helper functions for standard HTML tags and attributes.
Please refer to the documentation for a complete list.
-
Text nodes and attribute values are HTML-escaped by default.
-
Raw nodes write bytes directly without escaping.
-
JavaScript and CSS can be rendered as raw bytes; no sanitization is currently performed.
The module includes sub-packages for integration with popular frontend libraries and tools:
aria: Helpers for ARIA attributeshx: Helpers for htmx attributes (hx-get, hx-swap, etc.)ax: Helpers for Alpine.js directives (x-data, x-bind, etc.)svg: Example implementation of helpers for SVG icons and images.
This library is oriented towards performance.
It utilizes a custom pooling strategy and extensive use of unsafe for string/byte manipulation.
Some ideas are taken from slog to avoid a so-called "interface boxing".
Nodes are pooled by default to reduce GC pressure.
The following contract should be respected:
- Release only the root: call
Releaseonly on the root node.
Package automatically handles the recursive release of all child nodes, attributes, and connected structures. Calling release on an already released node will result in panic or a corrupted pool.
- Do not use same nodes twice: this leads to double-release.
If you need to render the same node multiple times, use owned nodes (see
node := htm.Span() htm.Div().Content( node, htm.Div().Content(node), // this will corrupt the pool )
Own).
You can also disable automatic pooling and manage the node lifecycle yourself.
- Do not reuse released nodes: once released, a node may be immediately reused by another goroutine.
Nodes can be marked as "owned" to prevent them from being returned to the pool (and prevent their subtree from being pooled). This might be useful for long-lived fragments or custom pooling strategies.
Automatic pooling can be completely disabled by setting NoPool.
Package optimizes for runtime performance rather than memory efficiency. Some internal structures trade memory for speed or simplicity (e.g. keeping order-preserving attribute storage). If you need the smallest possible memory footprint, you may want to benchmark and/or consider alternative approaches.
While the functional API enables composition, it can be slightly less efficient due to more function calls and allocation of values captured by closures. However, in many real-world cases (constants, inlining, pre-allocated pointers, non-capturing functions), this overhead is negligible.
Package contains an HTML parser to build a node tree from the provided input.
n, err := htm.Parse(data, flags)Available flags:
ParseReuseBuffer- parsed strings/bytes may reference the input buffer (zero allocations)ParseKeepWhitespace- keep all whitespace text nodesParseKeepEdgeWhitespace- keep non-empty text nodes as-is, ignore whitespace-only text nodesParseKeepComments- keep comment nodes (<!-- ... -->) as raw nodesParseAllowScriptContent- allow non-empty<script>contentParseTopLevelRawContent- parse exactly one top-level node and store its full inner HTML as one raw child node
Default behavior (no flags):
ParseReuseBufferis disabled- Whitespace outside whitespace-sensitive tags is ignored
- Comments are ignored
- Non-empty
<script>content is not allowed
Whitespace flag precedence:
ParseKeepWhitespaceoverridesParseKeepEdgeWhitespace- If neither whitespace flag is set, parser removes whitespace-only segments and trims leading/trailing whitespace where applicable
There are several benchmarks in the test files, but for a realistic assessment, it is always better to build and test your own trees.
htm.Div().Class("flex flex-col items-center p-7 rounded-2xl").Attr("id", "test").Content(
htm.Span().Class("a b c").Text("hello"),
htm.Span().Attr("data-x", "1").Text("world"),
)Building and rendering the tree above on a laptop takes:
Benchmark_Build-16 4029265 294.8 ns/op 0 B/op 0 allocs/op
Benchmark_Render/Default-16 4277874 282.6 ns/op 0 B/op 0 allocs/op
Benchmark_BuildRender/Default-16 1980176 606.2 ns/op 0 B/op 0 allocs/op
A page with header, footer and a table with 100 rows (~1400 nodes, see htm_test.go):
Benchmark_Page_Build-16 10000 114562 ns/op 271 B/op 0 allocs/op
Benchmark_Page_Render/Default-16 12933 93111 ns/op 0 B/op 0 allocs/op
Benchmark_Page_BuildRender/Default-16 6214 206834 ns/op 427 B/op 0 allocs/op
Parsing a ~30Kb page:
// Default - 420 nodes
Benchmark_Parse_Page/... 52030 115220 ns/op 20755 B/op 1424 allocs/op
// ReuseBuffer - 420 nodes
Benchmark_Parse_Page/... 81014 72750 ns/op 584 B/op 6 allocs/op
// Reuse + KeepEdge - 420 nodes
Benchmark_Parse_Page/... 81722 71658 ns/op 583 B/op 6 allocs/op
// Reuse + KeepWhitespace - 800 nodes
Benchmark_Parse_Page/... 67941 88953 ns/op 604 B/op 6 allocs/op
// Reuse + Keep all - 826 nodes
Benchmark_Parse_Page/... 65893 91337 ns/op 595 B/op 6 allocs/op
Pull requests are welcome. For major changes, please open an issue first.