Skip to content
gohacks.dev
Web

Templ Component Libraries and Design Systems in Go

Build a small templ component library with typed props, accessible defaults, a generic table and tests that parse the output to check labels, ids and roles.

16 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

A component library is what a set of templates becomes when a team stops repeating itself: one Button, one form field, one alert, written once with the right markup, the right accessibility attributes and a single place to change them. In a Go application, with templ, that library is ordinary Go code, which means it has types, tests, versions and documentation like any other package. The questions are the usual ones for any API design: what are the parameters, what are the defaults, and what is promised to callers.

Tested with Go 1.25.6, templ v0.3.1001 and golang.org/x/net v0.48.0, macOS on Apple Silicon (arm64)

This article builds a small library with a button, a form field, an alert, a dialog and a generic data table, renders a gallery of every variant, and tests the result with checks an accessibility reviewer would run. It follows the tutorial and layouts, uses the techniques of testing templ components, and relies on the safety rules from the escaping article. It belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with templ v0.3.1001.

What a library is for

Three things, in order of how much they matter later. Consistency: every button looks and behaves like every other because there is one Button. Correctness you only write once: the label, id and description wiring on a form field is fiddly, easy to get wrong, and the same for every field, so it belongs in one place. A smaller review surface: when pages are built from components with a documented API, reviewing a page means checking its data and layout, not re-reading every attribute of every input.

It is also a place to be disciplined about what is not a component. A page layout with a header, a footer and a slot is a component. A one-off block that appears once is markup in the page. The line moves as the application grows, and the cost of extracting too early is an API nobody wanted, so extract when the third copy appears.

The library

ui.templText
package main

import "fmt"

// ---- Button -----------------------------------------------------------

type Variant string

const (
	Primary Variant = "primary"
	Danger  Variant = "danger"
	Ghost   Variant = "ghost"
)

type Size string

const (
	Small  Size = "sm"
	Medium Size = "md"
)

type ButtonProps struct {
	Variant  Variant
	Size     Size
	Type     string // "button" (default), "submit" or "reset"
	Disabled bool
	Attrs    templ.Attributes
}

func (p ButtonProps) classes() []any {
	size := p.Size
	if size == "" {
		size = Medium
	}
	v := p.Variant
	if v == "" {
		v = Primary
	}
	return []any{"btn", "btn-" + string(v), "btn-" + string(size)}
}

templ Button(p ButtonProps) {
	<button
		type={ typeOr(p.Type) }
		class={ templ.Classes(p.classes()...) }
		disabled?={ p.Disabled }
		{ p.Attrs... }
	>
		{ children... }
	</button>
}

func typeOr(t string) string {
	if t == "" {
		return "button"
	}
	return t
}

// ---- Field ------------------------------------------------------------

type FieldProps struct {
	ID, Label, Name, Type, Value, Hint, Error string
	Required                                  bool
}

templ Field(p FieldProps) {
	<div class="field">
		<label for={ p.ID }>
			{ p.Label }
			if p.Required {
				<span aria-hidden="true">*</span>
			}
		</label>
		<input
			id={ p.ID }
			name={ p.Name }
			type={ typeOr2(p.Type) }
			value={ p.Value }
			required?={ p.Required }
			if p.Error != "" {
				aria-invalid="true"
			}
			aria-describedby={ describedBy(p) }
		/>
		if p.Hint != "" {
			<p id={ p.ID + "-hint" } class="hint">{ p.Hint }</p>
		}
		if p.Error != "" {
			<p id={ p.ID + "-error" } class="error" role="alert">{ p.Error }</p>
		}
	</div>
}

func typeOr2(t string) string {
	if t == "" {
		return "text"
	}
	return t
}

func describedBy(p FieldProps) string {
	var ids []string
	if p.Hint != "" {
		ids = append(ids, p.ID+"-hint")
	}
	if p.Error != "" {
		ids = append(ids, p.ID+"-error")
	}
	out := ""
	for i, id := range ids {
		if i > 0 {
			out += " "
		}
		out += id
	}
	return out
}

// ---- Alert ------------------------------------------------------------

type Severity string

const (
	Info    Severity = "info"
	Success Severity = "success"
	Warning Severity = "warning"
	Failure Severity = "error"
)

templ Alert(sev Severity, title string) {
	<div
		class={ "alert", "alert-" + string(sev) }
		if sev == Failure || sev == Warning {
			role="alert"
		} else {
			role="status"
		}
	>
		<strong>{ title }</strong>
		{ children... }
	</div>
}

// ---- Dialog -----------------------------------------------------------

templ Dialog(id, title string) {
	<dialog id={ id } aria-labelledby={ id + "-title" }>
		<h2 id={ id + "-title" }>{ title }</h2>
		{ children... }
		<form method="dialog">
			@Button(ButtonProps{Variant: Ghost, Type: "submit"}) {
				Close
			}
		</form>
	</dialog>
}

// ---- A generic table ----------------------------------------------------

type Column[T any] struct {
	Header string
	Cell   func(T) string
}

templ DataTable[T any](caption string, cols []Column[T], rows []T) {
	<table>
		<caption>{ caption }</caption>
		<thead>
			<tr>
				for _, c := range cols {
					<th scope="col">{ c.Header }</th>
				}
			</tr>
		</thead>
		<tbody>
			for _, r := range rows {
				<tr>
					for _, c := range cols {
						<td>{ c.Cell(r) }</td>
					}
				</tr>
			}
		</tbody>
	</table>
}

// ---- Gallery ------------------------------------------------------------

type Person struct {
	Name string
	Age  int
}

templ Gallery() {
	<main>
		for _, v := range []Variant{Primary, Danger, Ghost} {
			for _, s := range []Size{Small, Medium} {
				@Button(ButtonProps{Variant: v, Size: s}) {
					{ fmt.Sprintf("%s %s", v, s) }
				}
			}
		}
		@Button(ButtonProps{Type: "submit", Disabled: true, Attrs: templ.Attributes{"data-test": "save"}}) {
			Save
		}
		@Field(FieldProps{ID: "email", Label: "Email", Name: "email", Type: "email", Hint: "We never share it", Required: true})
		@Field(FieldProps{ID: "age", Label: "Age", Name: "age", Type: "number", Value: "7", Error: "Must be 13 or older"})
		@Alert(Info, "Heads up") {
			<p>Maintenance tonight.</p>
		}
		@Alert(Failure, "Failed") {
			<p>The save did not work.</p>
		}
		@Dialog("confirm", "Delete the project?") {
			<p>This cannot be undone.</p>
		}
		@DataTable("People", []Column[Person]{
			{Header: "Name", Cell: func(p Person) string { return p.Name }},
			{Header: "Age", Cell: func(p Person) string { return fmt.Sprint(p.Age) }},
		}, []Person{{"Ada", 36}, {"Bo", 41}})
	</main>
}

API decisions in that file

Props structs with useful zero values. Button(ButtonProps{}) renders a primary, medium, type="button" button; the zero value of each field means "the default". This is the convention that keeps call sites short (@Button(ButtonProps{Variant: Danger}) { Delete }) and makes the API extensible: adding a field to the struct is a compatible change, while adding a positional parameter breaks every caller. Compare the alternative, Button(variant, size, disabled, typ), where each call is a puzzle and a new option is a breaking change. For components with one or two parameters (Alert(sev, title)), positional arguments are fine.

Typed constants for choices. Variant, Size and Severity are string types with named constants. The compiler does not prevent Variant("purple"), but editors complete the constants, a test can range over the known set, and a typo like Variant: Dnager is a compile error. If a value must be validated at runtime, add a Valid() method; if the set is closed and you want the compiler to enforce it, use an unexported struct field with exported constructors.

Children for content, props for meaning. The text of a button is what is between the braces, { children... }, so it can be text, an icon plus text or any component. Things that are behavior or appearance (variant, disabled) are props. Resist adding a Label string prop next to children; there should be one way to do it.

An escape hatch. Attrs templ.Attributes is spread onto the element, so a caller can add data-test, hx-post or aria-label without the library needing a prop for each. It is deliberately last in the attribute list in this version, so a caller's type would be written twice; document what a caller may override and decide whether the library's attributes or the caller's win. The gallery's disabled Save button shows a caller's data-test attribute passing through.

Safe defaults. type="button" is the default for Button, since the HTML default is submit, and a button inside a form that submits it by accident is a classic bug. A caller who wants a submit button says so. The same instinct applies elsewhere: the alert uses role="alert" for warnings and errors, which interrupts a screen reader, and role="status" for information, which waits politely.

Accessibility wired in one place. Field renders a label with a matching for, an input with the same id, optional hint and error paragraphs with ids derived from the field id, and an aria-describedby that lists whichever exist. The error paragraph has role="alert" and the input gets aria-invalid="true". A caller writes one line and gets all of that. The required marker is aria-hidden because the required attribute already carries the meaning.

Generics. DataTable[T any] takes a caption, a list of Column[T] (a header and a function from row to string) and the rows. templ supports type parameters on components, so one table serves any row type with full type checking, and the caption and scope="col" headers are part of the component so every table gets them.

The gallery component renders every variant and state: all button variants at both sizes, a disabled submit button with a passed-through attribute, a required field with a hint, an invalid field with an error, an info and a failure alert, a dialog and a table. It is useful three ways: a page you open in development to see the whole system, the fixture for every test below, and the documentation that cannot go stale because it is code.

main.goGo
package main

import (
	"context"
	"fmt"
	"strings"
)

func main() {
	var sb strings.Builder
	if err := Gallery().Render(context.Background(), &sb); err != nil {
		panic(err)
	}
	fmt.Println(strings.ReplaceAll(sb.String(), "><", ">\n<"))
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go get golang.org/x/[email protected] >/dev/null 2>&1
go mod tidy >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go run .
Text
<main>
<button type="button" class="btn btn-primary btn-sm">primary sm</button>
<button type="button" class="btn btn-primary btn-md">primary md</button>
<button type="button" class="btn btn-danger btn-sm">danger sm</button>
<button type="button" class="btn btn-danger btn-md">danger md</button>
<button type="button" class="btn btn-ghost btn-sm">ghost sm</button>
<button type="button" class="btn btn-ghost btn-md">ghost md</button>
<button type="submit" class="btn btn-primary btn-md" disabled data-test="save">Save</button>
<div class="field">
<label for="email">Email <span aria-hidden="true">*</span>
</label> <input id="email" name="email" type="email" value="" required aria-describedby="email-hint"> <p id="email-hint" class="hint">We never share it</p>
</div>
<div class="field">
<label for="age">Age </label> <input id="age" name="age" type="number" value="7" aria-invalid="true" aria-describedby="age-error"> <p id="age-error" class="error" role="alert">Must be 13 or older</p>
</div>
<div class="alert alert-info" role="status">
<strong>Heads up</strong>
<p>Maintenance tonight.</p>
</div>
<div class="alert alert-error" role="alert">
<strong>Failed</strong>
<p>The save did not work.</p>
</div>
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete the project?</h2>
<p>This cannot be undone.</p>
<form method="dialog">
<button type="submit" class="btn btn-ghost btn-md">Close</button>
</form>
</dialog>
<table>
<caption>People</caption> <thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Age</th>
</tr>
</thead> <tbody>
<tr>
<td>Ada</td>
<td>36</td>
</tr>
<tr>
<td>Bo</td>
<td>41</td>
</tr>
</tbody>
</table>
</main>

The output is the rendered gallery with a line break after each tag. The buttons show the class lists (btn btn-danger btn-sm), the disabled submit button carries data-test, the required field has aria-describedby="email-hint" pointing at its hint, the invalid field has aria-invalid="true" and points at its error, the info alert has role="status" and the failure alert role="alert", and the dialog's Close button is type="submit" inside a method="dialog" form.

Tests that enforce the rules

The most valuable tests for a component library are not golden files; they are rules about the whole system, applied to the gallery, parsed as HTML.

ui_test.goGo
package main

import (
	"context"
	"strings"
	"testing"

	"golang.org/x/net/html"
)

// parse renders a component and returns the HTML tree.
func parse(t *testing.T, render func(*strings.Builder) error) *html.Node {
	t.Helper()
	var sb strings.Builder
	if err := render(&sb); err != nil {
		t.Fatal(err)
	}
	doc, err := html.Parse(strings.NewReader(sb.String()))
	if err != nil {
		t.Fatal(err)
	}
	return doc
}

func walk(n *html.Node, fn func(*html.Node)) {
	if n.Type == html.ElementNode {
		fn(n)
	}
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		walk(c, fn)
	}
}

func attr(n *html.Node, key string) (string, bool) {
	for _, a := range n.Attr {
		if a.Key == key {
			return a.Val, true
		}
	}
	return "", false
}

func text(n *html.Node) string {
	var sb strings.Builder
	var f func(*html.Node)
	f = func(n *html.Node) {
		if n.Type == html.TextNode {
			sb.WriteString(n.Data)
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			f(c)
		}
	}
	f(n)
	return strings.TrimSpace(sb.String())
}

func gallery(t *testing.T) *html.Node {
	return parse(t, func(sb *strings.Builder) error { return Gallery().Render(context.Background(), sb) })
}

func TestIDsAreUnique(t *testing.T) {
	seen := map[string]bool{}
	walk(gallery(t), func(n *html.Node) {
		if id, ok := attr(n, "id"); ok {
			if seen[id] {
				t.Errorf("duplicate id %q", id)
			}
			seen[id] = true
		}
	})
}

func TestEveryInputHasALabel(t *testing.T) {
	labels := map[string]bool{}
	walk(gallery(t), func(n *html.Node) {
		if n.Data == "label" {
			if f, ok := attr(n, "for"); ok {
				labels[f] = true
			}
		}
	})
	walk(gallery(t), func(n *html.Node) {
		if n.Data == "input" {
			if id, _ := attr(n, "id"); !labels[id] {
				t.Errorf("input %q has no <label for>", id)
			}
		}
	})
}

func TestDescribedByPointsAtRealElements(t *testing.T) {
	ids := map[string]bool{}
	walk(gallery(t), func(n *html.Node) {
		if id, ok := attr(n, "id"); ok {
			ids[id] = true
		}
	})
	walk(gallery(t), func(n *html.Node) {
		if d, ok := attr(n, "aria-describedby"); ok && d != "" {
			for _, id := range strings.Fields(d) {
				if !ids[id] {
					t.Errorf("aria-describedby refers to %q, which does not exist", id)
				}
			}
		}
	})
}

func TestInvalidFieldsSayTrue(t *testing.T) {
	found := false
	walk(gallery(t), func(n *html.Node) {
		if v, ok := attr(n, "aria-invalid"); ok {
			found = true
			if v != "true" {
				t.Errorf("aria-invalid=%q; the value must be \"true\", a bare attribute means nothing", v)
			}
		}
	})
	if !found {
		t.Error("no invalid field in the gallery to check")
	}
}

func TestButtonsHaveNamesAndDialogsCanClose(t *testing.T) {
	walk(gallery(t), func(n *html.Node) {
		if n.Data == "button" && text(n) == "" {
			t.Error("a button has no accessible name")
		}
		if n.Data == "form" {
			if m, _ := attr(n, "method"); m == "dialog" {
				// A button that closes the dialog must submit the dialog form.
				walk(n, func(b *html.Node) {
					if b.Data == "button" {
						if ty, _ := attr(b, "type"); ty != "submit" {
							t.Errorf("the dialog's Close button has type %q and would not close it", ty)
						}
					}
				})
			}
		}
	})
}

func TestTablesHaveCaptionsAndScopedHeaders(t *testing.T) {
	walk(gallery(t), func(n *html.Node) {
		if n.Data == "table" {
			hasCaption := false
			walk(n, func(c *html.Node) {
				if c.Data == "caption" {
					hasCaption = true
				}
				if c.Data == "th" {
					if s, _ := attr(c, "scope"); s == "" {
						t.Error("a <th> has no scope")
					}
				}
			})
			if !hasCaption {
				t.Error("a table has no <caption>")
			}
		}
	})
}

func TestAlertRolesMatchSeverity(t *testing.T) {
	want := map[Severity]string{Info: "status", Success: "status", Warning: "alert", Failure: "alert"}
	for sev, role := range want {
		doc := parse(t, func(sb *strings.Builder) error { return Alert(sev, "x").Render(context.Background(), sb) })
		walk(doc, func(n *html.Node) {
			if got, ok := attr(n, "role"); ok && got != role {
				t.Errorf("%s alert has role %q, want %q", sev, got, role)
			}
		})
	}
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go get golang.org/x/[email protected] >/dev/null 2>&1
go mod tidy >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go test -race -count=1 -v ./... 2>&1 | grep -E "^(--- |ok|FAIL|PASS)"
Text
--- PASS: TestIDsAreUnique (0.00s)
--- PASS: TestEveryInputHasALabel (0.00s)
--- PASS: TestDescribedByPointsAtRealElements (0.00s)
--- PASS: TestInvalidFieldsSayTrue (0.00s)
--- PASS: TestButtonsHaveNamesAndDialogsCanClose (0.00s)
--- PASS: TestTablesHaveCaptionsAndScopedHeaders (0.00s)
--- PASS: TestAlertRolesMatchSeverity (0.00s)
PASS
ok  	example.test/lab	1.276s

Each test states a rule, not an example. Ids are unique, which is easy to break when a component is rendered twice with a hard-coded id. Every input has a label for that matches it. Every aria-describedby refers to elements that exist. Invalid fields say aria-invalid="true". Buttons have text. Tables have captions and scoped headers. Alert roles follow severity.

Two of these tests exist because of mistakes I made writing the first draft. I had written aria-invalid?={ p.Error != "" }, using templ's boolean-attribute syntax; it rendered a bare aria-invalid, which assistive technology treats as no value, not as true. The fix is a conditional attribute that writes aria-invalid="true", and the rule is in TestInvalidFieldsSayTrue. And the dialog's Close button inherited the library's default type="button", which inside a method="dialog" form does not submit and therefore does not close the dialog; the fix is Type: "submit" for that one button, and the rule is in the last-but-one test. Neither error shows up in a screenshot, and both are the kind that a lint-style test finds in seconds and a code review often misses.

Styling without coupling

The components emit class names and say nothing about what they look like. That is deliberate. The classes (btn btn-primary btn-md) can be styled by a plain CSS file, by Tailwind's @apply in a stylesheet or by utility classes written directly in the component, as in the admin dashboard. Keep design tokens (colors, spacing, radii) as CSS custom properties on :root, so a theme or dark mode is a different set of variables and not a different set of components. Avoid inline style attributes with data in them, for the injection reasons in the escaping article. If a component needs its own CSS, a css component generates a scoped class and emits its <style> once per page, or a templ.NewOnceHandle can include a shared stylesheet link a single time.

Packaging, versions and change

For one application, a package inside the module (internal/ui) is enough. For several, publish it as its own module and give it the discipline of any library. Commit the generated _templ.go files, because consumers will not run your generator, and note the templ version they were built with: generated code depends on the runtime package, so the application's go.mod must require a compatible github.com/a-h/templ. Use semantic versioning for the exported API, and be honest about what counts as breaking. Adding an optional prop field is compatible. Changing a default is not: if Button defaulted to type="submit" and now defaults to button, forms that relied on the old behavior stop submitting. Changing the generated markup is not either, because applications write CSS and tests against it. Keep a changelog, and keep the gallery's golden files under review so a markup change is visible in the diff, as in the testing article.

Documenting the API in the code

A library is only as usable as its documentation, and the cheapest documentation is the kind that sits next to the code. Give every component a doc comment before its declaration, in the same style as Go functions: what it renders, what each prop does, what the zero value means and what the caller may override. templ keeps comments above templ declarations in the generated Go, so go doc ./ui Button shows them in the terminal and pkg.go.dev shows them for a published module. Add Example functions with // Output: comments for the two or three components people copy most; go test then verifies that the documented markup is what the component produces. Link the gallery route from the README so a newcomer can see every component before reading any code.

For the interaction patterns (dialogs, tabs, menus, comboboxes), use the WAI-ARIA Authoring Practices as the specification. They describe which roles, states and keyboard behavior each pattern needs, and they turn a vague "make it accessible" into a checklist you can write lint rules for. The native elements, <dialog> and <details>, give most of it for free, which is why the library above uses <dialog> rather than a div with a role.

Build or adopt

Teams often ask whether to write all this or adopt a kit. Both are reasonable. A kit gives you a large set of components and a visual language quickly, and it brings the costs of someone else's markup, an update schedule, and the work of wrapping each component in typed Go props. A small library of your own costs more at the start and fits exactly; it also forces you to decide what your system promises. The middle path that works well: write the half dozen components every page uses (button, field, alert, dialog, table), take CSS and design tokens from an existing system, and keep the markup rules in lint tests so the two parts agree.

Growing it

Add components when a third copy appears, and write the lint rule at the same time. Candidates in order: a form wrapper that carries the CSRF token and the error summary (forms), a pager, a toast region, an empty state, and icons as small components that return inline SVG with aria-hidden and a title option. Keep each in its own file with a doc comment that lists the props and what the zero value does, so go doc shows an API reference. The generic table above is a good reminder that the library should stay data-agnostic: components take what they display, and the application decides where it comes from. The syntax used in the file is summarised in the cheat sheet, and the project guide is at templ.guide.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.