gomb is a small Go library for building HTML programmatically using a fluent,
type-safe API. There are no templates, no string concatenation, and no reflection —
just regular Go functions and method chaining.
go get github.com/ernlel/gomb
| Pain point with text templates | How gomb solves it |
|---|---|
| Syntax errors only found at runtime | Go compiler checks everything |
| Hard to compose and reuse fragments | Components are plain Go functions |
| Escaping easy to forget | T() and A() HTML-escape automatically |
| Attribute order is non-deterministic in templates | gomb sorts attributes alphabetically |
| No IDE refactoring support | Full Go tooling: rename, find-references, … |
import . "github.com/ernlel/gomb" // dot-import makes E, If, Map, etc. available unqualified
page := E("html").A("lang", "en").C(
E("head").C(
E("meta").A("charset", "UTF-8"),
E("title").T("My App"),
),
E("body").C(
E("h1").T("Hello, World!"),
E("p").T("Welcome to gomb."),
),
)
fmt.Println(page.ToString())Output:
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>
My App
</title>
</head>
<body>
<h1>
Hello, World!
</h1>
<p>
Welcome to gomb.
</p>
</body>
</html>Every short method has a long-form alias. Use whichever reads better in context:
// Short (terse DSL)
page := E("html").A("lang", "en").C(
E("head").C(E("title").T("My App")),
E("body").C(E("h1").T("Hello"), E("p").T("Welcome")),
)
// Long (self-documenting)
page := El("html").Attr("lang", "en").Children(
El("head").Children(El("title").Text("My App")),
El("body").Children(El("h1").Text("Hello"), El("p").Text("Welcome")),
)
// Named inline (closest to HTML)
page := Html(
Head(TitleElement(Txt("My App"))),
Body(H1(Txt("Hello")), P(Txt("Welcome"))),
).Attr("lang", "en")| Short | Long |
|---|---|
E(tag) |
El(tag) |
.A(pairs...) |
.Attr(pairs...) |
.T(text) |
.Text(text) |
.C(elems...) |
.Children(elems...) |
For maximum IDE autocomplete and compile-time safety, the html sub-package
provides named constructor functions for every HTML element. Import alongside
gomb:
import (
. "github.com/ernlel/gomb" // core API
. "github.com/ernlel/gomb/pkg/html" // named constructors
)Use the inline style for compact, HTML-like code — pass attributes and children directly as arguments:
page := Html(
Head(
Meta(Attr{Key: "charset", Value: "UTF-8"}),
TitleElement(Txt("My App")),
),
Body(
H1(Txt("Hello, World!")),
P(Txt("Welcome to gomb.")),
),
).Attr("lang", "en")Or use the chained style with explicit .Attr() / .Children() / .Text():
page := Html().Attr("lang", "en").Children(
Head().Children(
Meta().Attr("charset", "UTF-8"),
TitleElement().Text("My App"),
),
Body().Children(
H1().Text("Hello"),
P().Text("Welcome to gomb."),
),
)Both produce identical output. Txt(s) is shorthand for E("").T(s) —
a text node without a wrapping tag.
The html package is a separate module (github.com/ernlel/gomb/pkg/html) and
depends only on gomb. Import it only when you want named constructors; the
core package stays minimal.
All 114 standard elements — Anchor() to Wbr(). A handful use an Element
suffix to avoid colliding with existing gomb functions:
| HTML tag | Constructor | Reason |
|---|---|---|
<a> |
Anchor() |
Collides with .A() / .Attr() setters |
<input> |
InputElement() |
Common variable name |
<script> |
ScriptElement() |
Common variable name |
<style> |
StyleElement() |
Collides with .Style() helper |
<title> |
TitleElement() |
Common variable name |
<data> |
DataElement() |
Collides with .Data() helper |
<map> |
MapElement() |
Collides with Map[T]() |
<var> |
VarElement() |
Collides with var keyword |
<time> |
TimeElement() |
Collides with time package |
Regenerate with: go run ./cmd/gen-html
| Function / method | Description |
|---|---|
E(tag) / El(tag) |
Create an element |
.A(pairs...) / .Attr(pairs...) |
Set attributes from key-value pairs (HTML-escaped) |
.T(text) / .Text(text) |
Set text content (HTML-escaped) |
.C(children...) / .Children(children...) |
Append child elements |
.Class(names...) |
Shorthand for the class attribute (uses Classes()) |
.Id(id) |
Shorthand for the id attribute |
.Clone() |
Shallow copy — independent Attributes map |
.When(cond, fn) |
Apply fn to element only when cond is true |
.ToString() |
Render to an HTML string |
.Render(w) |
Write HTML to an io.Writer, returns (int64, error) |
Raw(html) |
Insert pre-rendered HTML verbatim |
Fragment(els...) |
Tag-less wrapper (no extra element) |
None |
Empty element — renders nothing |
If(cond, el) |
Conditionally include an element |
IfElse(cond, a, b) |
Choose between two elements |
When(cond, fn) |
Lazy conditional — fn only called if cond is true |
Map(slice, fn) |
Transform a slice into []*Element |
Classes(...names) |
Space-join class names, skipping empties |
.Data(key, value) |
Shorthand for data-* attributes |
.Style(css) |
Shorthand for the style attribute |
.Attrs(pairs...) / .As(pairs...) |
Apply multiple Attr pairs at once |
NS(prefix) |
Create a namespaced attribute builder |
.With(fns...) |
Apply composable transformer functions |
Div(), Span(), … |
Named constructors in github.com/ernlel/gomb/pkg/html |
Txt(text) |
Tag-less text node — shorthand for E("").T(text) |
T() and A() escape &, <, >, and " automatically — user-supplied
data is safe by default.
E("p").T(`<script>alert("xss")</script>`)
// → <p>
// <script>alert("xss")</script>
// </p><script> and <style> content is never entity-encoded (browsers parse
these tags as raw text).
E("script").T(`if (a < b) console.log("ok")`)
// → <script>
// if (a < b) console.log("ok")
// </script>Use Raw() to inject pre-rendered fragments or external HTML:
E("div").C(Raw("<span>already safe HTML</span>"))Fragment(els...) creates a tag-less wrapper — it groups multiple elements
together without emitting any surrounding HTML tag. This is useful when a
component needs to return several sibling elements, or when you want to insert
multiple items into a parent without an extra <div>.
// Without Fragment you'd need a wrapper div:
E("div").C(
E("dt").T("Go"),
E("dd").T("A statically typed language."),
)
// Fragment emits the children directly — no wrapper tag:
func definition(term, desc string) *gomb.Element {
return Fragment(
E("dt").T(term),
E("dd").T(desc),
)
}
dl := E("dl").C(
definition("Go", "A statically typed language."),
definition("gomb", "An HTML builder for Go."),
)Output:
<dl>
<dt>
Go
</dt>
<dd>
A statically typed language.
</dd>
<dt>
gomb
</dt>
<dd>
An HTML builder for Go.
</dd>
</dl>Another common use: returning a group of table cells or list items from a helper without wrapping them in an extra element:
func navLinks(links []Link) *gomb.Element {
items := Map(links, func(l Link) *gomb.Element {
return E("li").C(E("a").A("href", l.URL).T(l.Label))
})
return Fragment(items...) // no wrapping <ul>/<div>
}
E("ul").C(navLinks(siteLinks))NS(prefix) creates a function that prepends the given prefix to every attribute key.
Combine with .Attrs() to keep htmx, Alpine.js, and custom data-* attributes tidy
without repeating the prefix on every call.
hx := gomb.NS("hx-")
E("button").Attrs(
hx("get", "/api/users"),
hx("swap", "outerHTML"),
hx("target", "#user-list"),
hx("trigger", "click"),
).T("Reload")Output:
<button hx-get="/api/users" hx-swap="outerHTML" hx-target="#user-list" hx-trigger="click">
Reload
</button>x := gomb.NS("x-")
E("div").Attrs(
x("data", `{"open": false}`),
x("show", "open"),
x("cloak", ""),
)data := gomb.NS("data-")
E("li").Attrs(
data("user", "42"),
data("role", "admin"),
data("sort", "name"),
)
// → <li data-user="42" data-role="admin" data-sort="name">Attrs() works alongside .A() and .Data():
hx := gomb.NS("hx-")
E("input").
A("type", "text").
A("class", "form-input").
Data("validate", "email").
Attrs(
hx("get", "/validate"),
hx("trigger", "keyup changed delay:500ms"),
).Class(names...) is a shorthand for .A("class", Classes(names...)):
E("button").Class(
"btn",
IfElse(isPrimary, "btn-primary", "btn-secondary"),
IfElse(isLarge, "text-lg", "text-sm"),
)
// → <button class="btn btn-primary text-lg">Classes() joins class names into a single string, skipping empty strings. Useful when
you need the string value outside of .Class():
E("button").A("class", Classes(
"btn",
IfElse(isPrimary, "btn-primary", "btn-secondary"),
IfElse(isDisabled, "opacity-50 cursor-not-allowed", ""),
)).Id(id) is a shorthand for .A("id", id):
E("div").Id("main").Class("container")
// → <div class="container" id="main">E("div").Style("display: flex; align-items: center; gap: 0.5rem")
// → <div style="display: flex; align-items: center; gap: 0.5rem">.With() accepts one or more func(*Element) transformers and applies them in order.
Package common attribute patterns as reusable, composable modifiers:
// Transformer factories — functions that return transformers
btn := func(e *Element) {
e.A("type", "submit").A("class", Classes("btn", "rounded"))
}
primaryBtn := func(e *Element) {
e.A("class", Classes("btn", "btn-primary", "rounded"))
}
withTooltip := func(text string) func(*Element) {
return func(e *Element) {
e.Data("tooltip", text)
}
}
// Compose them
E("button").T("Save").With(btn, withTooltip("Save your changes"))
E("button").T("Delete").With(primaryBtn, withTooltip("Permanently delete"))
// Transformers can also modify children:
withIcon := func(e *Element) {
e.C(E("span").A("class", "icon").T("★"))
}
E("a").A("href", "/star").T("Favorite").With(withIcon)Transformers are just functions — they're testable, composable, and can be defined in packages alongside your components.
.When(cond, fn) applies fn to the element only when cond is true. Unlike the
package-level When() (which lazily builds elements for .C()), this method stays
inside the chain:
E("input").
A("type", "checkbox").
When(checked, func(e *Element) { e.A("checked", "") }).
When(disabled, func(e *Element) { e.A("disabled", "") }).Clone() returns a shallow copy — the Attributes map is independent, but ChildNodes
slice references are shared. Use it to build a base template and stamp out variants:
baseInput := E("input").Class("input").A("type", "text")
nameInput := baseInput.Clone().A("name", "name").Id("name-field")
emailInput := baseInput.Clone().A("name", "email").Id("email-field")
// baseInput is unchangedRender(w) writes directly to any io.Writer and returns (int64, error) —
useful for logging bytes written or checking write errors:
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
n, err := E("p").T("Hello").Render(w)
log.Printf("wrote %d bytes", n)
}Components are Go functions that return *Element. Any parameters, loops, and
conditions are just Go:
func Button(label string, primary bool) *Element {
return E("button").
A("class", Classes("btn", IfElse(primary, "btn-primary", ""))).
A("type", "submit").
T(label)
}
func NavBar(links []NavLink) *Element {
items := Map(links, func(l NavLink) *Element {
return E("li").C(E("a").A("href", l.URL).T(l.Label))
})
return E("nav").C(E("ul").C(items...))
}Wrap content in a layout function:
func Layout(title string, body *gomb.Element) *gomb.Element {
return E("html").A("lang", "en").C(
E("head").C(
E("meta").A("charset", "UTF-8"),
E("title").T(title),
E("link").A("rel", "stylesheet").A("href", "/static/app.css"),
),
E("body").C(Header(), body, Footer()),
)
}
func IndexPage() *gomb.Element {
return Layout("Home", E("main").C(
E("h1").T("Welcome"),
E("p").T("This is the home page."),
))
}// If — render an element only when a condition is true
E("ul").C(
If(user.IsAdmin, E("li").C(E("a").A("href", "/admin").T("Admin"))),
E("li").C(E("a").A("href", "/profile").T("Profile")),
)
// IfElse — choose between two elements
IfElse(user.LoggedIn,
E("a").A("href", "/logout").T("Log out"),
E("a").A("href", "/login").T("Log in"),
)
// Go if/else inside a component function
func greeting(user *User) *gomb.Element {
if user == nil {
return E("p").T("Hello, guest!")
}
return E("p").T("Hello, " + user.Name + "!")
}
// Map — render a list from a slice
names := []string{"Alice", "Bob", "Carol"}
E("ul").C(Map(names, func(name string) *gomb.Element {
return E("li").T(name)
})...)
// When — lazy conditional: fn is only called if the condition is true
// Use When instead of If when the element is expensive or has side-effects
E("ul").C(
When(user != nil, func() *Element {
// This closure only runs when user != nil
return E("li").C(E("a").A("href", "/profile").T(user.Name))
}),
E("li").C(E("a").A("href", "/home").T("Home")),
)Tailwind classes are regular strings — use Classes() for terse conditional
composition:
E("button").A("class", Classes(
"bg-blue-600 hover:bg-blue-700",
"text-white font-bold",
"py-2 px-4 rounded",
IfElse(disabled, "opacity-50 cursor-not-allowed", ""),
)).T("Click me")For Tailwind CDN (development / demos):
E("link").
A("rel", "stylesheet").
A("href", "https://cdn.jsdelivr.net/npm/tailwindcss@3/dist/tailwind.min.css")htmx directives are HTML attributes — use .A() directly, or define a namespace
with NS() for less repetition:
// Vanilla A() calls
E("button").
A("hx-get", "/api/users").
A("hx-target", "#user-list").
A("hx-swap", "innerHTML").
T("Reload users")
// Or with NS() — no repeating "hx-"
hx := NS("hx-")
E("button").Attrs(
hx("get", "/api/users"),
hx("target", "#user-list"),
hx("swap", "innerHTML"),
).T("Reload users")
// An inline-edit form using namespace
E("form").Attrs(
hx("put", fmt.Sprintf("/users/%d", user.ID)),
hx("target", fmt.Sprintf("#user-%d", user.ID)),
hx("swap", "outerHTML"),
).C(
E("input").A("type", "text").A("name", "name").A("value", user.Name),
E("button").A("type", "submit").T("Save"),
)See examples/htmx/ for a full task-list app.
Alpine.js x-data, x-show, @click, :class, and other directives are just
HTML attributes. JSON in x-data is HTML-escaped automatically and the browser
decodes it back correctly.
x := NS("x-")
// Counter
E("div").Attrs(x("data", `{"count": 0}`)).C(
E("button").A("@click", "count--").T("-"),
E("span").A("x-text", "count"),
E("button").A("@click", "count++").T("+"),
)
// Toggle visibility
E("div").Attrs(
x("data", `{"open": false}`),
x("show", "open"),
x("cloak", ""),
).C(
E("button").A("@click", "open = !open").T("Toggle"),
E("p").A("x-show", "open").T("Visible when open is true"),
)See examples/alpinejs/ for accordion, tabs, modal,
and live search examples.
Rendered HTML strings can be cached and replayed freely.
Static component (sync.Once)
var (
navOnce sync.Once
navHTML string
)
func Nav() string {
navOnce.Do(func() { navHTML = buildNav().ToString() })
return navHTML
}Per-key TTL cache
var cache sync.Map
type entry struct { html string; exp time.Time }
func Cached(key string, ttl time.Duration, build func() *gomb.Element) string {
if v, ok := cache.Load(key); ok {
if e := v.(entry); time.Now().Before(e.exp) {
return e.html
}
}
html := build().ToString()
cache.Store(key, entry{html, time.Now().Add(ttl)})
return html
}See examples/caching/ for a complete demo including
an HTTP response-level cache middleware.
The pkg/markup_to_gomb package converts an HTML string into the equivalent gomb
Go code. Useful for migrating existing templates or pasting in HTML from a
design tool.
import "github.com/ernlel/gomb/pkg/markup_to_gomb"
code, err := markup_to_gomb.GenerateGombFromMarkup(`
<div class="card">
<h2>Title</h2>
<p>Body text.</p>
</div>
`)
fmt.Println(code)See examples/markup_to_gomb/ for a
file-based conversion tool.
| Example | What it shows |
|---|---|
examples/components/ |
Reusable component functions served over HTTP |
examples/layout/ |
Multi-page site with a shared layout, header, and footer (Tailwind) |
examples/htmx/ |
Dynamic task list with htmx partial updates |
examples/alpinejs/ |
Client-side interactivity (counter, accordion, tabs, modal, live search) |
examples/caching/ |
Component cache, per-key TTL cache, and response-level cache middleware |
examples/test1/ |
Full landing page generated from gomb (Tailwind) |
examples/html-elements/ |
Named constructors for all 114 HTML elements — inline and chained styles |
examples/markup_to_gomb/ |
Convert HTML markup to gomb code |
The following void elements are rendered without a closing tag automatically:
area, base, br, col, embed, hr, img, input, link, meta,
param, source, track, wbr.
E("img").A("src", "logo.png").A("alt", "Logo")
// → <img alt="Logo" src="logo.png" />Pass an empty string value to render a boolean attribute without a value:
E("input").A("type", "checkbox").A("checked", "").A("disabled", "")
// → <input checked disabled type="checkbox" />The Element struct fields are exported so you can inspect or walk the tree
in custom tooling, tests, or middleware:
el := E("div").A("class", "card").C(
E("h2").T("Title"),
E("p").T("Body"),
)
// Inspect
tag := el.Tag // "div"
cls := el.Attributes["class"] // "card"
txt := el.ChildNodes[0].TextContent // "Title"
// Walk the tree
var walk func(e *Element)
walk = func(e *Element) {
fmt.Println(e.Tag)
for _, c := range e.ChildNodes {
walk(c)
}
}
walk(el)Builder methods (A, C, T, etc.) mutate the element in place and return
the same pointer for chaining — read any field at any time.
The short form (E, A, T, C) reads like a DSL and is ideal for page-level
composition where you're writing lots of markup:
page := E("html").A("lang", "en").C(
E("head").C(E("title").T("Home")),
E("body").C(E("h1").T("Welcome")),
)The long form (El, Attr, Text, Children) is self-documenting — use it in
public packages, shared components, and code that non-Go developers might read:
func SiteLayout(title string, body *Element) *Element {
return El("html").Attr("lang", "en").Children(
El("head").Children(El("title").Text(title)),
El("body").Children(Header(), body, Footer()),
)
}One method call per line — attributes grouped, children indented:
// Good
E("button").
A("type", "submit").
A("class", Classes("btn", "btn-primary")).
Data("action", "save").
T("Save")
// Hard to scan
E("button").A("type","submit").A("class",Classes("btn","btn-primary")).Data("action","save").T("Save")Extract anything used more than once into a function. Return *Element, accept any
parameters you need:
func Card(title, body string) *Element {
return E("div").A("class", "card").C(
E("h3").A("class", "card-title").T(title),
E("p").A("class", "card-body").T(body),
)
}
func CardList(items []CardData) *Element {
return E("div").A("class", "card-list").C(
Map(items, func(d CardData) *Element {
return Card(d.Title, d.Body)
})...,
)
}When a prefix repeats across many elements, define a namespace at the package or function level:
var hx = gomb.NS("hx-")
func liveSearch() *Element {
return E("input").As(
hx("get", "/search"),
hx("trigger", "keyup changed delay:300ms"),
hx("target", "#results"),
hx("swap", "innerHTML"),
).A("type", "search").A("placeholder", "Search...")
}func(*Element) is the signature — wrap in a closure to accept
configuration:
func sizedText(size string) func(*Element) {
return func(e *Element) {
e.A("class", Classes("text-"+size))
}
}
E("p").T("Title").With(sizedText("xl"))
E("p").T("Body").With(sizedText("base"))| Pattern | When |
|---|---|
Go if/else inside a component |
The condition drives logic, not just presence |
If(cond, el) |
Inline inside a .C() call, element already built |
When(cond, fn) |
The element is expensive or has side-effects |
IfElse(cond, a, b) |
Swapping between two inline elements |
Classes("base", IfElse(cond, "on", "off")) |
Conditional CSS classes |
// Go if/else — multiple branches or complex logic
func StatusBadge(status string) *Element {
var color string
switch status {
case "active": color = "bg-green-500"
case "paused": color = "bg-yellow-500"
default: color = "bg-gray-500"
}
return E("span").A("class", Classes("badge", color)).T(status)
}
// If — simple inline presence
E("ul").C(
If(isAdmin, E("li").C(E("a").A("href", "/admin").T("Admin"))),
E("li").C(E("a").A("href", "/home").T("Home")),
)
// Classes + IfElse — conditional CSS
E("button").A("class", Classes(
"btn",
IfElse(active, "btn-active", "btn-inactive"),
IfElse(large, "text-lg", ""),
))A layout function accepts title and body, returns the full page:
func Layout(title string, body *Element) *Element {
return E("html").A("lang", "en").C(
E("head").C(
E("meta").A("charset", "UTF-8"),
E("title").T(title),
),
E("body").C(Header(), body, Footer()),
)
}Every page becomes one line:
func HomePage() *Element { return Layout("Home", homeContent()) }
func AboutPage() *Element { return Layout("About", aboutContent()) }MIT — see LICENSE. Contributions welcome — see CONTRIBUTING.md.