Skip to content
Trilha
Chapters

Learn

UI kit

Trilha's default component kit, compatible with shadcn/ui themes, and how it becomes yours to customize.

Every project created with trilha new ships with the ui kit: typed components in Go (ui.Button, ui.Card, ui.Field...) that render classes from a small, prefixed CSS (ui-*), plus 200 lines of JavaScript for what HTML does not do on its own (tabs, disappearing toasts, conditional fields, light/dark theme). No dependencies: the three files live in public/ and are yours.

public/ui.theme.css   ← colors and radius: edit it or paste a ready-made theme
public/ui.css         ← the components; `trilha ui` updates it
public/ui.js          ← behaviors; `trilha ui` updates it

The theme contract is the one from shadcn/ui (MIT): the same variables, --background, --primary, --radius, in oklch. Generate a theme at ui.shadcn.com/themes or tweakcn.com, paste the :root { … } .dark { … } block into ui.theme.css and you are done: nothing in Go changes. Trilha uses neither React nor Tailwind; only the theme is compatible.

Wiring the kit#

The generated layout already does this; in an existing project, run trilha ui and add:

h.Head(…, ui.Head(c)),          // ui.theme.css, ui.css, saved theme, ui.js
h.Body(ui.Body(),               // theme font and colors
	ui.Header(ui.Brand("/", "My app"), ui.Nav(ui.NavLink("/", "Home", true)), ui.Spacer(), ui.ThemeToggle()),
	h.Main(ui.Container(children)),
	ui.Flashes(c),              // where toasts show up, c.Flash included
)

Variants are attributes#

A component is a function returning h.Node; variants and sizes are class attributes you mix with any h attribute, in any order. h merges repeated class attributes into one.

app/…/page.go
ui.Row(
	ui.Button(h.Text("Save")),
	ui.Button(ui.Secondary(), h.Text("Draft")),
	ui.Button(ui.Outline(), h.Text("Cancel")),
	ui.Button(ui.Ghost(), ui.Icon("settings")),
	ui.Button(ui.Destructive(), ui.Sm(), ui.Icon("trash"), h.Text("Delete")),
	ui.Badge(h.Text("new")),
	ui.Badge(ui.Outline(), h.Text("beta")),
)
result
newbeta

Forms#

ui.Field joins label, control, help and error with the right id/for and aria-*. ui.ShowWhen("field", "value") shows the group only while the field has that value and disables the hidden controls, so they do not travel in the POST. Without JavaScript, all fields simply appear.

app/…/page.go
h.Form(h.Class("ui-stack"),
	ui.Field("kind", "Customer type",
		ui.Select(h.ID("kind"), h.Name("kind"),
			h.Option(h.Value("person"), h.Text("Individual")),
			h.Option(h.Value("company"), h.Text("Company")))),
	ui.Field("tax-id", "Personal tax ID", ui.Input(h.ID("tax-id"), h.Name("tax-id")),
		ui.With(ui.ShowWhen("kind", "person"))),
	ui.Field("company-id", "Company tax ID", ui.Input(h.ID("company-id"), h.Name("company-id")),
		ui.Help("14 digits"), ui.With(ui.ShowWhen("kind", "company"))),
	ui.CheckRow(ui.Switch(h.ID("invoice"), h.Name("invoice")), "Send an invoice", "invoice"),
	ui.Field("email", "E-mail for the invoice", ui.Input(h.ID("email"), h.Type("email")),
		ui.With(ui.ShowWhen("invoice"))),
)
result

14 digits

After a POST, render the error in the field itself (ui.Error("Title is required") + ui.Invalid() on the control) and a toast that disappears on its own: ui.Toast("success", "Saved!", 4000) inside the layout's toaster. The examples/blog app does both in app/blog/novo/page.go.

Saying what happened, and asking before destroying#

A POST that works ends in a redirect, and the redirect eats the news. c.Flash writes it in a signed cookie, and the ui.Flashes(c) in the layout shows it on the page that follows:

c.Flash(ui.FlashSuccess, "Post deleted")
return c.Redirect("/blog")

ui.FlashInfo, ui.FlashSuccess and ui.FlashError are the kinds. On a fragment answer there is no redirect to survive, so the messages travel in a header and ui.js shows them — the call in the handler is the same. Without TRILHA_SECRET nothing is written, and the app says so once in the log.

Before something irreversible, ui.Confirm puts the question on the form itself:

h.Form(h.Method("post"), h.Action("/blog/"+p.Slug), trilha.CSRFInput(c),
	ui.Confirm("Delete this post?", "There is no undo."),
	ui.Submit(ui.Destructive(), h.Text("Delete")))

ui.js holds the submit, opens the kit's dialog and only then lets it through. Without JavaScript the form submits straight away; when that is not good enough, ask on a page of its own (GET /blog/{slug}/delete rendering the same form), which works either way.

Cards, tabs, progress#

app/…/page.go
ui.Card(
	ui.CardHeader(ui.CardTitle("Monthly goal"), ui.CardDescription("7 of 10 posts")),
	ui.CardContent(
		ui.Progress(7, 10),
		ui.Tabs("goal",
			ui.Tab{Label: "Summary", Content: h.P(h.Text("3 to go."))},
			ui.Tab{Label: "Details", Content: h.P(h.Text("Tabs with keyboard support and ARIA."))},
		),
	),
	ui.CardFooter(ui.Button(ui.Sm(), h.Text("Publish"))),
)
result

Monthly goal

7 of 10 posts

3 to go.

Dialog and toasts#

ui.Dialog is a native <dialog>: it closes with Esc, a click outside or ui.DialogClose; the form inside it does a normal POST.

app/…/page.go
ui.Row(
	ui.DialogTrigger("confirm", ui.Outline(), h.Text("Open dialog")),
	ui.Dialog("confirm", "Publish now?",
		ui.DialogDescription("The post becomes visible to everyone."),
		ui.DialogFooter(ui.DialogClose(ui.Ghost(), h.Text("Later")), ui.DialogClose(h.Text("Publish")))),
	ui.Button(ui.Secondary(), h.Data("ui-toast", "Saved!"), h.Text("Show toast")),
)
// data-ui-toast shows a toast on click; from the server, after a POST,
// render ui.Toast("success", "Saved!", 4000) inside ui.Toaster().
result

Publish now?

The post becomes visible to everyone.

Tables with hierarchy#

ui.Depth(n) indents the first cell: it serves charts of accounts, category trees and any server-rendered drill-down. ui.Num() aligns numbers to the right.

app/…/page.go
ui.Table(
	h.Thead(h.Tr(h.Th(h.Text("Account")), h.Th(ui.Num(), h.Text("Budget")), h.Th(ui.Num(), h.Text("Actual")))),
	h.Tbody(
		h.Tr(ui.Depth(0), h.Td(h.Strong(h.Text("Expenses"))), h.Td(ui.Num(), h.Text("12,000")), h.Td(ui.Num(), h.Text("11,240"))),
		h.Tr(ui.Depth(1), h.Td(h.Text("Staff")), h.Td(ui.Num(), h.Text("8,000")), h.Td(ui.Num(), h.Text("8,000"))),
		h.Tr(ui.Depth(1), h.Td(h.Text("Marketing")), h.Td(ui.Num(), h.Text("4,000")), h.Td(ui.Num(), ui.Badge(ui.Destructive(), h.Text("3,240")))),
	),
)
result
AccountBudgetActual
Expenses12,00011,240
Staff8,0008,000
Marketing4,0003,240

Pagination and hints#

ui.Pagination renders page navigation as real links, so a page can be shared, reloaded and indexed. The current page is a <span> with aria-current — a link to where you already are is a link to nowhere — and the first page has no previous, so nothing is rendered for it. The window keeps the first page, the last one and the ones around the current, with an ellipsis over each gap, so the footer does not grow with the table.

ui.Tooltip writes the hint into title, which is the browser's own tooltip and works with ui.js off. With the script on the page the title is removed — two tooltips is worse than none — a bubble with role="tooltip" takes its place, the target gets aria-describedby, and the hint answers to hover, keyboard focus and touch, closing with Escape.

app/…/page.go
ui.Row(
	ui.Tooltip("Only the author sees the drafts",
		ui.Button(ui.Outline(), h.Text("Drafts"))),
	ui.Pagination(ui.Pages{
		Page: 4, Total: 12,
		Href: func(n int) string { return "?page=" + strconv.Itoa(n) },
	}),
)
result

Updating and customizing#

  • trilha ui rewrites ui.css and ui.js when you update Trilha; it never touches ui.theme.css. If you edited ui.css, it warns and only overwrites with --force.
  • To change a component, edit ui.css (it is yours) or override it in style.css. For a new component, write the function in your own package: func Price(v int) h.Node { return h.Span(h.Class("ui-badge price"), …) }.
  • Icons: ui.Icon("check"), a small set from Lucide (ISC). ui.Icons() lists the names. For others, paste the SVG into your own h.Raw.

Challenge#

Build a sign-up form where the "Company" field only appears when "Type" is "Company" and, when submitted empty, the error shows in the field and a toast disappears after 3 s.

Show solution
func Page(c *trilha.Ctx) (h.Node, error) {
	msg := c.Query("error")
	return h.Form(h.Method("post"), h.Class("ui-stack"), trilha.CSRFInput(c),
		ui.Field("type", "Type", ui.Select(h.ID("type"), h.Name("type"),
			h.Option(h.Value("individual"), h.Text("Individual")), h.Option(h.Value("company"), h.Text("Company")))),
		ui.Field("company", "Company", ui.Input(h.ID("company"), h.Name("company"), h.If(msg != "", ui.Invalid())),
			ui.Error(msg), ui.With(ui.ShowWhen("type", "company"))),
		ui.Submit(h.Text("Sign up")),
		h.If(msg != "", ui.Toaster(ui.Toast("error", msg, 3000))),
	), nil
}

func POST(c *trilha.Ctx) error {
	if c.Form("type") == "company" && strings.TrimSpace(c.Form("company")) == "" {
		return c.Redirect("/signup?error=Company+is+required")
	}
	return c.Redirect("/signup/done")
}