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.

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
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
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.
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<"))
}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 .<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.
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)
}
})
}
}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)"--- 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.276sEach 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.


