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 itThe 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.
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")),
)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.
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"))),
)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.
For a form submitted with fetch, retain event.currentTarget before the first await. Use ui.formPending(form) while the request runs and ui.formError(form, message, {field}) so the failure remains visible inside the dialog, with focus and aria-invalid on the responsible field.
ui.DialogTrigger("async-profile", ui.Outline(), h.Text("Edit profile"))
ui.Dialog("async-profile", "Edit profile",
h.Form(h.Method("post"), h.Class("ui-stack"),
ui.FormError(),
ui.Field("async-email", "Email",
ui.Input(h.ID("async-email"), h.Name("email"), h.Type("email"))),
ui.DialogFooter(ui.DialogClose(ui.Ghost(), h.Text("Cancel")), ui.Submit(h.Text("Save"))),
),
)Use this pattern — async-form
A form that posts without leaving the page and answers a 422 beside the field that is wrong.
Components
ui.Swap ui.FormError ui.Field ui.Input ui.InvalidIf ui.Errors ui.Submit
Data it needs
type Profile struct with form and validate tags, bound by c.Bind; Save(c, p) error stores a valid one. A trilha.FieldErrors from Bind is the 422.
Accessibility
- On a 422 the kit swaps the form and moves focus to the first field with aria-invalid.
- ui.Field ties the message to the input with aria-describedby.
- ui.FormError is the summary a screen reader hears when the answer arrives.
The whole page.go (compiles, at most 60 lines)
// Package asyncform is the async-form pattern: a form that posts without
// leaving the page, and a 422 that comes back with the message beside the
// field and the focus on it.
package asyncform
import (
"errors"
"net/http"
"github.com/emersonjoe/trilha"
"github.com/emersonjoe/trilha/h"
"github.com/emersonjoe/trilha/ui"
)
// Profile is what the form edits; the tags are its rules.
type Profile struct {
Name string `form:"name" validate:"required,max=80" label:"Name"`
Email string `form:"email" validate:"required,email" label:"E-mail"`
}
// Save is where a valid profile goes — your store.
var Save = func(c *trilha.Ctx, p Profile) error { return nil }
// Page draws the form.
func Page(c *trilha.Ctx) (h.Node, error) { return form(c, Profile{}, nil), nil }
// POST validates by the tags. The 422 answers the fragment: the kit swaps it
// in place and focuses the first field marked invalid.
func POST(c *trilha.Ctx) error {
var p Profile
if err := c.Bind(&p); err != nil {
var fe trilha.FieldErrors
if errors.As(err, &fe) {
return c.Render(http.StatusUnprocessableEntity, form(c, p, fe))
}
return err
}
if err := Save(c, p); err != nil {
return err
}
c.Flash(ui.FlashSuccess, "Saved.")
return c.Redirect(c.Request().URL.Path)
}
func form(c *trilha.Ctx, p Profile, errs trilha.FieldErrors) h.Node {
return h.Form(h.ID("profile"), h.Method("post"), ui.Swap("profile"), h.Class("ui-stack"),
trilha.CSRFInput(c), ui.FormError(),
ui.Field("name", "Name", ui.Input(h.ID("name"), h.Name("name"), h.Value(p.Name),
ui.InvalidIf(errs, "name")), ui.Errors(errs, "name")),
ui.Field("email", "E-mail", ui.Input(h.ID("email"), h.Name("email"), h.Type("email"),
h.Value(p.Email), ui.InvalidIf(errs, "email")), ui.Errors(errs, "email")),
ui.Submit(h.Text("Save")))
}trilha ui patterns async-form · get_pattern {"name":"async-form"}
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.
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")),
)Cards, tabs, progress#
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"))),
)Monthly goal
7 of 10 posts
3 to go.
Tabs with keyboard support and ARIA.
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.
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().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.
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")))),
),
)| Account | Budget | Actual |
|---|---|---|
| Expenses | 12,000 | 11,240 |
| Staff | 8,000 | 8,000 |
| Marketing | 4,000 | 3,240 |
Use this pattern — master-detail
A list on one side and the chosen row on the other, the choice in the address.
Components
ui.DataTable ui.Columns ui.ListState ui.Empty ui.EmptyOpts ui.Card ui.CardHeader ui.CardTitle ui.CardDescription ui.CardContent ui.Badge ui.Grid ui.Cols
Data it needs
type Query struct{ trilha.ListParams; ID string `form:"id"` }; Customers(c, q) answers one page and the total, Find(c, id) one row or trilha.ErrNotFound.
Accessibility
- Each row is a link (ListState.RowHref), so the keyboard reaches the detail.
- The detail is a section with an aria-label, a landmark to jump to.
- The choice lives in the address: back and reload keep it.
The whole page.go (compiles, at most 60 lines)
// Package masterdetail is the master-detail pattern: the list on one side and
// the chosen row on the other, the choice in the address so it can be shared
// and survives a reload.
package masterdetail
import (
"github.com/emersonjoe/trilha"
"github.com/emersonjoe/trilha/h"
"github.com/emersonjoe/trilha/ui"
)
// Customer is one row. Replace it with your type.
type Customer struct{ ID, Name, Email, Plan string }
// Query is the listing plus the chosen row.
type Query struct {
trilha.ListParams
ID string `form:"id"`
}
// Customers answers one page and the total; Find answers one row or
// trilha.ErrNotFound — both from your store.
var (
Customers = func(c *trilha.Ctx, q trilha.ListParams) ([]Customer, int, error) { return nil, 0, nil }
Find = func(c *trilha.Ctx, id string) (Customer, error) { return Customer{}, trilha.ErrNotFound }
)
var columns = ui.Columns[Customer]{
{Key: "name", Label: "Name", Sort: true, Cell: func(x Customer) h.Node { return h.Text(x.Name) }},
{Key: "plan", Label: "Plan", Cell: func(x Customer) h.Node { return ui.Badge(ui.Outline(), h.Text(x.Plan)) }},
}
// Page renders the list and, when the address names one, its detail.
func Page(c *trilha.Ctx) (h.Node, error) {
var q Query
if err := c.Bind(&q); err != nil {
return nil, err
}
rows, total, err := Customers(c, q.ListParams)
if err != nil {
return nil, err
}
list := ui.DataTable(c, columns, rows, ui.ListState{Params: q.ListParams, Total: total, Caption: "Customers",
RowHref: func(i int) string { return q.Href("id", rows[i].ID) }})
var detail h.Node = ui.Empty(ui.EmptyOpts{Title: "Pick a customer to see the details"})
if q.ID != "" {
x, err := Find(c, q.ID)
if err != nil {
return nil, err
}
detail = ui.Card(ui.CardHeader(ui.CardTitle(x.Name), ui.CardDescription(x.Email)),
ui.CardContent(ui.Badge(h.Text(x.Plan))))
}
return ui.Stack(ui.PageHeader("Customers"),
ui.Grid(ui.Cols(1, 2), list, h.Section(h.Aria("label", "Details"), detail))), nil
}trilha ui patterns master-detail · get_pattern {"name":"master-detail"}
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.
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) },
}),
)The frame of an internal app#
ui.Shell is the sidebar, the top bar and the user's menu, written once. The item whose Href is the longest prefix of Current gets aria-current="page" — an exact match always wins, so /items/42/edit lights up /items, not /. ui.PageHeader is the title of the screen inside it, with the way back and the actions of the screen. See Shell for Hide, the collapsing sidebar and IconNode.
ui.Shell(c, ui.ShellOpts{
Brand: h.Text("Acervo"),
Current: "/documents",
Nav: []ui.NavGroup{
{Label: "Work", Items: []ui.NavItem{
{Href: "/dashboard", Label: "Dashboard", Icon: "house"},
{Href: "/documents", Label: "Documents", Badge: "12"},
}},
},
User: ui.UserMenu{Name: "Ana Paula", Detail: "ana@acervo.com", Items: []h.Node{
ui.MenuLink("/profile", h.Text("Profile")),
}},
},
ui.PageHeader("Documents",
ui.ButtonLink("/documents/new", ui.Icon("plus"), h.Text("New document"))),
)Documents
Where you are, and who is signed in#
ui.Breadcrumb renders the trail as real links, with the current page as a <span aria-current="page"> instead of a link to itself. ui.Avatar falls back to initials when there is no picture.
ui.Row(
ui.Breadcrumb(
ui.Crumb{Label: "Documents", Href: "/documents"},
ui.Crumb{Label: "Contract 41"},
),
ui.Spacer(),
ui.Avatar("AP", ""),
)A menu that opens a small list of actions is ui.MenuTrigger and ui.Menu sharing an id, on the browser's own popover attribute — no script of its own.
ui.Row(
ui.MenuTrigger("actions", ui.Outline(), ui.Icon("settings"), h.Text("Actions")),
ui.Menu("actions",
ui.MenuItem(h.Text("Duplicate")),
ui.MenuItem(h.Text("Archive")),
ui.MenuLink("/documents/41/edit", h.Text("Edit")),
),
)More content behind one click#
ui.Collapsible is a styled <details>: no script decides whether it is open, the browser already does.
ui.Stack(
ui.Collapsible("How does handoff work?",
h.P(h.Text("The ticket moves to another agent without losing the history."))),
ui.Collapsible("Can a closed ticket be reopened?",
h.P(h.Text("Yes, up to 7 days after it closes."))),
)How does handoff work?
The ticket moves to another agent without losing the history.
Can a closed ticket be reopened?
Yes, up to 7 days after it closes.
ui.Tabs works the same wherever it appears — arrows and Home/End move the selection, the first tab starts open:
ui.Tabs("settings",
ui.Tab{Label: "General", Content: h.P(h.Text("Name, time zone, language."))},
ui.Tab{Label: "Notifications", Content: h.P(h.Text("E-mail and push, per event."))},
ui.Tab{Label: "Security", Content: h.P(h.Text("Password and open sessions."))},
)Name, time zone, language.
E-mail and push, per event.
Password and open sessions.
The boxes layout is built from#
ui.Row, ui.Stack and ui.Grid are <div>s with one class each — a row, a column, a responsive grid — and ui.Separator is the rule between sections of a screen.
ui.Stack(
ui.Row(ui.H3(h.Text("This month's summary")), ui.Spacer(), ui.Muted(h.Text("September"))),
ui.Separator(),
ui.Grid(
ui.Card(ui.CardContent(ui.Muted(h.Text("Orders")), h.P(h.Text("128")))),
ui.Card(ui.CardContent(ui.Muted(h.Text("Revenue")), h.P(h.Text("$8,420")))),
ui.Card(ui.CardContent(ui.Muted(h.Text("Open")), h.P(h.Text("9")))),
),
)This month's summary
September128
$8,420
9
Before the data arrives#
ui.Skeleton is the placeholder shape; ui.Progress is a bar at a known position. Neither needs a script — ui.Defer, further down, is what swaps a skeleton for the real thing.
ui.Stack(
ui.Progress(7, 10),
ui.Skeleton(h.StyleAttr("height: 1.2rem")),
ui.Skeleton(h.StyleAttr("height: 1.2rem; width: 70%")),
)A key, and a snippet#
ui.Kbd and ui.Code sit inline with the sentence around them.
h.P(
h.Text("Open search with "), ui.Kbd("⌘"), ui.Kbd("K"),
h.Text(", or run "), ui.Code("trilha dev"), h.Text(" in the terminal."),
)Open search with ⌘K, or run trilha dev in the terminal.
One value of an enum, as a badge#
ui.Status(enum, value) reads the label and the tone a trilha.Enum declared once — the same declaration a <select>'s options and a form's validation already use. A value the enum no longer knows renders muted, not blank.
var Status = trilha.Enum{
{Value: "shipped", Label: "Shipped", Tone: "info"},
{Value: "delivered", Label: "Delivered", Tone: "success"},
{Value: "cancelled", Label: "Cancelled", Tone: "danger"},
}
ui.Row(
ui.Status(Status, "shipped"),
ui.Status(Status, "delivered"),
ui.Status(Status, "cancelled"),
)Nothing to show, and what could not load#
ui.Empty is the screen with nothing on it: an icon, a title, a hint that says what to do next, and a way out. ui.EmptyError is the same shape for a screen that failed to load — the error itself only appears in development, never on the page a visitor sees.
ui.Row(
ui.Empty(ui.EmptyOpts{
Icon: "info",
Title: "No documents yet",
Hint: "Send the first PDF and classification starts on its own.",
Action: ui.ButtonLink("/documents/new", h.Text("Send a document")),
}),
ui.EmptyError(c, "Could not load the documents", err,
ui.ButtonLink("/documents", h.Text("Try again"))),
)Could not load the documents
A form in several screens#
ui.Steps draws where somebody is: what is behind them links back, what is ahead is plain text, and the current step carries aria-current="step". The state between screens is Ctx.Draft — see the wizard recipe.
ui.Steps([]ui.Step{
{Label: "File", Href: "/import/1"},
{Label: "Mapping", Href: "/import/2"},
{Label: "Confirm"},
}, 2)- File
- Mapping
- Confirm
The slow part, a moment later#
A dashboard that needs seven queries should not hold the whole page for the one that takes two seconds. ui.Defer renders a placeholder now and asks for the fragment once the page has loaded; see Live fragments for the route it expects on the other end.
ui.Container(
ui.Grid(quickStats...),
ui.Defer(c, "insights", "/panel/insights", ui.DeferOpts{Height: "8rem"}),
ui.LiveScript(c),
)
// /panel/insights answers Ctx.Fragment with an element id="insights";
// ui.LiveScript(c) is what turns the automatic swap on.This part could not be loaded.
A file next to its metadata#
ui.Preview shows an image as an <img> you can open at full size, a document a browser renders inline, or — when the type cannot be shown in place — a card with a download button instead of a frame that renders blank.
ui.Row(
ui.Preview(c, logoURL, ui.PreviewOpts{Title: "Logo", Type: "image/png"}),
ui.Preview(c, "/attachments/report.zip", ui.PreviewOpts{
Title: "Report", Type: "application/zip", NoOpen: true,
}),
)What happens during a swap#
ui.Indicator(id) marks anything — a badge, a spinner — to appear only while the target with that id has been waiting past ui.PendingAfter's threshold (120 ms by default), and ui.NoTransition() turns off the crossfade for a trigger that fires often, like a live search.
ui.Row(
ui.ButtonLink("/orders/refresh", ui.Swap("list"), ui.PendingAfter(150), ui.NoTransition(),
h.Text("Refresh")),
ui.Spinner(ui.Indicator("list")),
)
// ui.Spinner(ui.Indicator("list")) only shows while target "list" is still
// waiting past ui.PendingAfter; ui.NoTransition turns off the crossfade for
// this trigger.Tables that live in the URL#
ui.DataTable is the screen every management app has: filter on top, table in the middle, pagination at the foot, and the whole state — page, order, search — in the address. See Listings for trilha.ListParams, the piece that reads it back.
ui.DataTable(c, ui.Columns[Doc]{
{Key: "name", Label: "File", Sort: true, Cell: func(d Doc) h.Node { return h.Text(d.Name) }},
{Key: "kind", Label: "Type", Cell: func(d Doc) h.Node { return h.Text(d.Kind) }},
{Key: "size", Label: "Size", Sort: true, Num: true, Cell: func(d Doc) h.Node { return h.Text(d.Size) }},
}, docs, ui.ListState{
Params: q.ListParams, Total: total, Search: "Search files",
RowHref: func(i int) string { return "/documents/" + docs[i].Slug },
})
// docs, total := repo.List(c.Context(), q.Sort, q.Asc(), q.Offset(), q.Limit(), q.Q)| File | Type | Size |
|---|---|---|
| contract-2024.pdf | 482 KB | |
| invoice-0093.pdf | 96 KB | |
| logo-final.png | Image | 128 KB |
| notes.txt | Text | 4 KB |
Use this pattern — list-with-filter
A listing somebody searches, filters, orders and pages, with all of it in the address.
Components
ui.DataTable ui.Columns ui.ListState ui.Select ui.SelectOptions ui.Option ui.Badge ui.Empty ui.EmptyOpts ui.PageHeader ui.Stack
Data it needs
type Query struct{ trilha.ListParams; Status string `form:"status"` } bound by c.Bind; Orders(c, q) ([]Order, int, error) answers one page and the total from your store, ordering only by the columns marked Sort.
Accessibility
- The table has a caption (ListState.Caption) that a screen reader announces.
- The filter select carries an aria-label, since it has no visible label.
- Sorting and paging are real links: they work with the keyboard and without JavaScript.
The whole page.go (compiles, at most 60 lines)
// Package listwithfilter is the list-with-filter pattern: a sortable table, a
// search box and one filter, paged — all of it in the address.
package listwithfilter
import (
"github.com/emersonjoe/trilha"
"github.com/emersonjoe/trilha/h"
"github.com/emersonjoe/trilha/ui"
)
// Order is one row. Replace it with your type.
type Order struct {
ID, Customer, Status string
Total float64
}
// Query is what the address says: the listing plus this screen's filter.
type Query struct {
trilha.ListParams
Status string `form:"status"`
}
// Orders is where the rows come from — your store, filtered, ordered and
// paged. It answers one page and the total.
var Orders = func(c *trilha.Ctx, q Query) ([]Order, int, error) { return nil, 0, nil }
var columns = ui.Columns[Order]{
{Key: "customer", Label: "Customer", Sort: true, Cell: func(o Order) h.Node { return h.Text(o.Customer) }},
{Key: "status", Label: "Status", Cell: func(o Order) h.Node { return ui.Badge(ui.Outline(), h.Text(o.Status)) }},
{Key: "total", Label: "Total", Sort: true, Num: true, Cell: func(o Order) h.Node { return h.Textf("%.2f", o.Total) }},
}
// Page answers the whole screen, or only the table when the kit asks for it.
func Page(c *trilha.Ctx) (h.Node, error) {
var q Query
if err := c.Bind(&q); err != nil {
return nil, err
}
if q.Status != "open" && q.Status != "paid" {
q.Status = "" // only a value this screen offers reaches the store
}
rows, total, err := Orders(c, q)
if err != nil {
return nil, err
}
status := ui.Select(h.Name("status"), h.Aria("label", "Status"), ui.SelectOptions([]ui.Option{
{Value: "", Label: "All"}, {Value: "open", Label: "Open"}, {Value: "paid", Label: "Paid"}}, q.Status))
table := ui.DataTable(c, columns, rows, ui.ListState{Params: q.ListParams, Total: total, ID: "orders",
Search: "Search customers", Filters: status, Caption: "Orders", Cards: true,
Empty: ui.Empty(ui.EmptyOpts{Title: "No orders match"})})
if c.Fragment() == "orders" {
return table, nil
}
return ui.Stack(ui.PageHeader("Orders"), table), nil
}trilha ui patterns list-with-filter · get_pattern {"name":"list-with-filter"}
Empty, and filtered down to empty#
A list with nothing in it and a list a search matched nothing in are two different screens; ui.DataTable draws the distinction on its own, in English, and ListState.Empty is how an application says it in its own language instead.
// the same call either way — the kit reads q.ListParams.Q and picks the message:
ui.DataTable(c, cols, docs, ui.ListState{Params: q.ListParams, Total: total, Search: "Search files"})
// GET /documents → "Nothing here yet"
// GET /documents?q=invoice-9999 → "No results for “invoice-9999”", with a link that clears q
//
// ListState.Empty replaces both when the kit's own words are not the app's language:
ui.DataTable(c, cols, docs, ui.ListState{
Params: q.ListParams, Total: total, Search: "Search files",
Empty: emptyFor(q.Q),
})| File |
|---|
Nothing here yet |
| File |
|---|
No results for “invoice-9999” Try another term, or clear the search to see everything. |
A hierarchy that opens node by node#
ui.Tree is a classification plan, a folder tree, an org chart: each node is a <details>, so it opens with no script at all. ui.TreePicker is the same tree as a form field — see Trees.
ui.Tree(ui.TreeOpts{
Nodes: plan.Roots(current), // with the path down to the current node already open
Label: "Classification",
Current: current,
})
ui.Field("code", "Classification", ui.TreePicker(ui.TreePickerOpts{
Name: "code",
Value: current,
Nodes: plan.Roots(current),
Label: "Classification",
}))100 Administrative
A field that searches as you type#
ui.Combobox is a text field the server searches — a classification among hundreds, a city among thousands — with no round trip for a list short enough to filter in the browser. See Combobox.
ui.Field("city", "City", ui.Combobox(ui.ComboboxOpts{
Name: "city_id", Value: doc.CityID, Label: doc.CityName,
Options: cities, // short list: filtered in the browser
Placeholder: "Search a city",
}))- São Paulo
- Rio de Janeiro
- Curitiba
One box, several kinds of thing#
ui.SearchBox and ui.SearchResults are the search of the top bar, over a trilha.Search — one index, several kinds, results grouped and counted. See Search.
var Search = trilha.NewSearch(trilha.SearchOpts{Store: trilha.SearchMemory()}).
Kind("document", trilha.KindOpts{Label: "Documents"}).
Kind("person", trilha.KindOpts{Label: "People"})
ui.SearchBox(c, "/search", ui.SearchBoxOpts{Value: c.Query("q")})
res, _ := Search.Query(c, c.Query("q"), trilha.SearchQuery{Limit: 10})
ui.SearchResults(c, res, ui.SearchResultsOpts{})Documents1
- Monthly report
Revenue and expenses for August
People1
- Ana Paula Reis
Finance team, writes the monthly report
The same screen, two languages#
ui.Date, ui.Bytes, ui.Duration and ui.Number read Config.Locale off the Ctx — the word changes, the call does not. See Formatting.
ui.Date(c, doc.CreatedAt) // Sep 8, 2026 3:04 PM
ui.Date(c, doc.CreatedAt, ui.Relative()) // 3min ago
ui.Bytes(c, doc.Size) // 1.4 MB
ui.Duration(c, job.Elapsed) // 2 h 5 min
ui.Number(c, total) // 12,345
ui.Number(c, price, ui.Decimals(2)) // 1,234.56
// the same code, with cfg.Locale = "pt-BR" instead of "en", writes the column beside it| Config.Locale | en | pt-BR |
|---|---|---|
| Date | ||
| Date only | ||
| Time only | ||
| Relative | ||
| Bytes | 1.4 MB | 1,4 MB |
| Duration | 2 h 5 min | 2 h 5 min |
| Number | 12,345 | 12.345 |
| Number, 2 decimals | 12,345.68 | 12.345,68 |
Four numbers and the drawings beside them#
ui.Stat, ui.Bars, ui.Sparkline and ui.Donut draw a dashboard on the server, in SVG, with no charting library. See Charts.
ui.Grid(
ui.Card(ui.CardContent(ui.Stat("Open", "1,204", ui.StatHint("+38 this week")), ui.Sparkline(weekly, ui.SparkOpts{}))),
ui.Card(ui.CardHeader(ui.CardTitle("Documents by type")), ui.CardContent(ui.Bars(data, ui.ChartTitle("Documents by type")))),
ui.Card(ui.CardHeader(ui.CardTitle("Share of the month")), ui.CardContent(ui.Donut(data, ui.ChartTitle("Share of the month")))),
ui.Card(ui.CardContent(ui.Stat("Avg. response", "2 h 5 min"), ui.SparklineTitle(weekly, ui.SparkOpts{Width: 160}, ui.ChartTitle("Response time, 7 days")))),
)Documents by type
| Documents | 1,204 |
|---|---|
| Contracts | 340 |
| Invoices | 512 |
| Reports | 88 |
Share of the month
- Documents1,204
- Contracts340
- Invoices512
- Reports88
| Documents | 1,204 |
|---|---|
| Contracts | 340 |
| Invoices | 512 |
| Reports | 88 |
| 1 | 18 |
|---|---|
| 2 | 22 |
| 3 | 19 |
| 4 | 27 |
| 5 | 24 |
| 6 | 31 |
| 7 | 29 |
Use this pattern — dashboard-chart
The numbers on top and the drawings beside them, server-rendered SVG with no chart library.
Components
ui.Stat ui.SparklineTitle ui.SparkOpts ui.ChartTitle ui.Bars ui.Donut ui.Datum ui.Card ui.CardHeader ui.CardTitle ui.CardContent ui.Grid ui.Cols
Data it needs
Load(c) (Numbers, error): the formatted totals, []ui.Datum per category and the weekly []float64 — counted by your store, not by the page.
Accessibility
- Every chart carries a ui.ChartTitle, which becomes the SVG's accessible name.
- The values are in the chart's text as well, so the numbers are read and not only drawn.
- The cards stack to one column on a narrow screen (ui.Cols(1, 2)).
The whole page.go (compiles, at most 60 lines)
// Package dashboardchart is the dashboard-chart pattern: the numbers on top,
// the drawings beside them — SVG from the server, no chart library, readable
// without JavaScript.
package dashboardchart
import (
"github.com/emersonjoe/trilha"
"github.com/emersonjoe/trilha/h"
"github.com/emersonjoe/trilha/ui"
)
// Numbers is what the dashboard shows. The strings are already formatted:
// the store counts, the page does not.
type Numbers struct {
Open, Paid string
ByType []ui.Datum
Weekly []float64
}
// Load computes the numbers — your store, one query per card at most.
var Load = func(c *trilha.Ctx) (Numbers, error) { return Numbers{}, nil }
// Page renders the dashboard.
func Page(c *trilha.Ctx) (h.Node, error) {
n, err := Load(c)
if err != nil {
return nil, err
}
return ui.Stack(
ui.PageHeader("Dashboard"),
ui.Grid(ui.Cols(1, 2),
ui.Card(ui.CardContent(ui.Stat("Open orders", n.Open,
ui.SparklineTitle(n.Weekly, ui.SparkOpts{Width: 160}, ui.ChartTitle("Open orders, last 7 days"))))),
ui.Card(ui.CardContent(ui.Stat("Paid this month", n.Paid))),
),
ui.Grid(ui.Cols(1, 2),
ui.Card(ui.CardHeader(ui.CardTitle("Orders by type")),
ui.CardContent(ui.Bars(n.ByType, ui.ChartTitle("Orders by type")))),
ui.Card(ui.CardHeader(ui.CardTitle("Share by type")),
ui.CardContent(ui.Donut(n.ByType, ui.ChartTitle("Share by type")))),
),
), nil
}trilha ui patterns dashboard-chart · get_pattern {"name":"dashboard-chart"}
A cell that refreshes itself#
ui.Poll asks a fragment's route again on a clock; ui.Live and ui.On do it when the server says so instead. See Live fragments.
h.Div(h.ID("queue"), ui.Poll("6s", "/jobs/queue/status"),
ui.Stat("Jobs in queue", "3"),
)
// GET /jobs/queue/status answers Ctx.Fragment with the same element id;
// ui.LiveScript(c) on the layout is what turns the automatic swap on.
ui.On("queue:changed", "/jobs/queue/status") // wakes on an SSE event too
ui.Live("/events") // opened once, in the layoutUpdating and customizing#
trilha uirewritesui.cssandui.jswhen you update Trilha; it never touchesui.theme.css. If you editedui.css, it warns and only overwrites with--force.- To change a component, edit
ui.css(it is yours) or override it instyle.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 ownh.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")
}What comes next: the ready-made screens#
The catalogue above is the pieces. Ready-made screens is one screen built out of them, end to end — the frame, the numbers, the table that lives in the URL, the slow part, the file, the form in steps — with the reason each piece is where it is.