Skip to content
Trilha
Chapters

Reference

ui

The kit's components, variants, assets and the theme contract.

import "github.com/emersonjoe/trilha/ui" — stdlib only. Components return h.Node with ui-* classes from public/ui.css; behaviors live in public/ui.js.

Assets#

SymbolRole
ui.Head(c) h.Node<link> for ui.theme.css and ui.css, inline script (with nonce) that applies the saved theme, <script defer src=ui.js>; honors c.Base()
ui.Body() h.Nodeui-body class for the <body>
ui.Asset(name) []byteembedded content of ui.css, ui.theme.css, ui.js, ui.nav.js, ui.upload.js, ui.recorder.js, ui.live.js, ui.chat.js or ui.island.js
ui.Filesthe six names, in the order trilha ui writes them

Variants and sizes#

ui.Secondary(), ui.Outline(), ui.Ghost(), ui.Destructive(), ui.LinkStyle(), ui.Sm(), ui.Lg(), ui.IconSize(). They are class attributes: valid on Button, Submit, ButtonLink, Badge and Alert (each one translates to its own class, e.g. ui-btn-outline, ui-badge-outline).

Components#

FunctionRenders
Container, Stack, Row, Grid, Spacerlayout: max width, column, row, responsive grid — see the layout boxes
Cols(n...)how many columns a Grid has, 1 to 6: Cols(2, 4) is two on a phone and four from 1024px; without it the grid fits as many 16rem columns as the width takes
Header(children...), Brand(href, name), Nav(...), NavLink(href, label, current), Sidebar(...)sticky top bar, brand, navigation (with aria-current), side column
H1, H2, H3, Lead, Muted, Code(s), Kbd(s)typography — see a key and a snippet
Button, Submit, ButtonLink(href, ...)<button type=button>, <button type=submit>, <a> styled as a button
Card, CardHeader, CardTitle(s), CardDescription(s), CardContent, CardFootercard
Input, Textarea, Select, Checkbox, Radio, Switch, Labelcontrols (Switch has role=switch)
Field(id, label, control, opts ...FieldOpt)label + control + Help(s) + Error(s); With(nodes...) puts attributes on the group. The options are ui.FieldOpt values, so a form that builds its fields in a loop passes them around as data
FormError(...)accessible error summary for asynchronous forms; the runtime also creates it when omitted
CheckRow(control, label, id)checkbox/switch next to its label
Invalid()aria-invalid="true" (red ring)
Errors(errs, field)Field option: shows the message from errs[field] (a trilha.FieldErrors) if any — or of its lowest field[N], the name c.Files gives a refused file
InvalidIf(errs, field)Invalid() only when there is an error for the field, or for one of its field[N]
SelectOptions([]Option{{Value, Label}}, selected)<option>s marking the selected one; Value: "" is a placeholder (disabled) and is selected when nothing matches
Checked(bool)conditional checked (round trip of checkbox/switch/radio)
ShowWhen(field, values...)data-ui-show-when: shows the element only with the value (or any non-empty value); hidden controls are disabled
Combobox(ComboboxOpts{...}, attrs...), ComboboxOptions(items, of)a text field that searches a list — see Combobox and demo
Dropzone(DropzoneOpts{...}, children...)drag-and-drop area over a file input — see Upload with progress
SchemaForm(schema, values, errs, ...)a form defined by data: one field per trilha.SchemaField — see Validation
Badge, Alert(title, ...), AlertDescription(...)badge and alert (role=alert)
Toaster(...), Toast(kind, text, fadeMs)toast stack; kind = "", success, error; fadeMs > 0 disappears on its own
Flashes(c)the toaster with the messages of c.Flash — put it in the layout; FlashInfo, FlashSuccess and FlashError are the kinds, and FlashFadeMs is how long one stays before it fades — see demo
Table(...), Cards(), Num(), Depth(n)scrollable table; row-as-card below 640px — see Listings; numeric cell; row indentation (tree)
Tabs(id, Tab{Label, Content}...), TabsWithOptions(id, TabsOpts{Selected}, ...)accessible tabs; Tab.Href gives each view a real address and Selected chooses the server-rendered panel — see demo
Dialog(id, title, ...), DialogDescription(s), DialogFooter(...), DialogTrigger(id, ...), DialogClose(...)native <dialog> with showModal; DialogTrigger given h.Href is a link, and with Swap on the dialog's body it loads the body on demand
Confirm(title, description)attributes for a <form>: ui.js asks in a dialog before submitting, fragment forms included. The confirming button repeats the pressed button's label; the other says Cancel, or what h.Data("ui-confirm-cancel", "…") says. Without JavaScript the form submits straight away — see demo
Menu(id, ...), MenuItem(...), MenuLink(href, ...), MenuTrigger(id, ...)menu with the native popover attribute — see demo
Pagination(Pages{Page, Total, Href, Prev, Next, Label, Attrs})page navigation as links; the current page is a <span> with aria-current, the edges are absent instead of disabled, and a window of seven slots keeps the first and last page with … over each gap; one page renders nothing
Tooltip(text, ...)hint on what it wraps: title plus data-ui-tooltip, upgraded by ui.js into a bubble with role=tooltip and aria-describedby
Separatorrule between sections — see the layout boxes
Skeleton, Progress(value, max)loading placeholders — see demo
Breadcrumb(Crumb{Label, Href}...), Avatar(initials, src)navigation trail and the picture (or initials) of who is signed in — see demo
Collapsible(summary, ...)a styled <details> — see demo
ThemeToggle()button that switches light/dark (localStorage["ui-theme"])
CSVErrors(c, res, CSVErrorsOpts{...})what trilha.BindCSV rejected, by line and column — see CSV and demo
DataTable(c, Columns[T], rows, ListState)the listing: filter form, sortable headers, pagination and empty state, all in the URL — see Listings and demo
Swap(id)data-trilha-target: the <a> or <form> asks for element #id only and swaps it (fragments)
SecretOnce(c, secret), APIKeysTable(c, rows, opts)a key shown once, and the list of them — see Auth and demo
SettingsForm(c, section, errs)the administration screen of a trilha.Settings section, drawn from the struct — see App and demo
Tree(TreeOpts{...}), TreePicker(TreePickerOpts{...}), TreeItems, TreeScript(c)a hierarchy that opens node by node, and the field that picks one — see Trees and demo
AuditTable(c, records, AuditOpts{...})the trail c.Audit writes, with filter, pagination and CSV export — see Observability and demo
Steps([]Step{Label, Href}, current)the indicator of a form in several screens — see A form in steps and demo
Sheet(c, id, SheetOpts{Side, Width, Title, Modal, Open, Push}, children...), SheetBody(id, children...), SheetOpen(id), SheetClose(children...), SheetScript(c)a side panel whose body (#<id>-body) loads from the link's address as a fragment and stays open while a Navigate region beside it navigates; <aside role=complementary>, or <dialog> when modal — see A panel beside the page
Preview(c, src, PreviewOpts{...})a file shown beside its metadata: bar, frame, image or "cannot be previewed" — see Ctx, Uploads and demo
Audio(c, src, AudioOpts{Preload AudioPreload, ...})native accessible audio player with duration, open/download actions and an unsupported-format fallback
InstallApp(c, InstallAppOpts{...})progressive PWA install invitation; use it with trilha add pwa
Outbox(c), OfflineScript(c)what is waiting to be sent — the count, the last error and the "Send now" button — and the script that queues a form marked with trilha.OfflineForm when there is no network; use them with trilha add pwa-offline
Defer(c, id, src, DeferOpts{...})serves the page now and fills this part a moment later — see Live fragments and demo
Poll(every, src), Live(src), On(event, src), LiveScript(c)a fragment that refreshes on a clock or on an event from the server — see Live fragments and demo
NoPush()data-trilha-push="false": the swap leaves history alone
PushHistory()data-trilha-push="push": a GET form with Swap adds an entry per search instead of replacing its own
Follow(), NoFollow()a trigger follows its route's redirect in place (the default on a page with a Navigate region), or does not — see After the POST
Markdown(text, MarkdownOpts{...})model or visitor text as HTML, escaped by construction — see Markdown
Chat(c, ChatOpts{...}), ChatScript(c), ChatHTML(text)a conversation with an agent — see Chat
Icon(name, attrs...), Icons()inline Lucide SVG; unknown name → panic (programming error). NavItem.IconNode/EmptyOpts.IconNode draw the app's own node for an icon outside the set — see Shell
Patterns() []Patternthe kit's screen patterns as data — name, summary, components, a complete page.go of at most 60 lines that compiles, the data contract and the accessibility notes; see Patterns
APIUsage(c, data, opts)how much a key was used, where, and when it stopped — see Auth and demo
SearchBox(c, action, SearchBoxOpts{...}), SearchResults(c, res, SearchResultsOpts{...})the box in the top bar (Submit adds a button with that label) and the grouped result of a trilha.Search — see Search and demo
DeadlineCards(c, summary), DeadlineList(c, items, opts), DeadlineBadge(c, overdue)what expires and when, from a trilha.Deadlines summary — see DeadlineCards
ConnectionsPanel(c, conns, opts), ConnectionStatus(c, test), ParseConnectionForm(c)the external services and their secrets, with the Test button — see ConnectionsPanel
Shell(c, ShellOpts{...}, children...), PageHeader(title, actions...)the frame of an application with sections: side navigation, top bar, and the title of the screen with its buttons — Back{} above it, Subtitle("…") below — see Shell and demo
Stat(label, value, ...), StatHint(text, ...), Sparkline(values, SparkOpts{...}), SparklineTitle(values, SparkOpts{...}, ...), Bars([]Datum, ...), Donut([]Datum, ...), ChartTitle(name)a number on a panel and the drawing next to it, in SVG written by the server; ChartTitle is what makes the drawing an image with a name instead of decoration — see Charts and demo
Inbox(c, []InboxRow, InboxOpts{...}), InboxBadge(n)what is waiting for whoever is reading, and the count beside the menu item (zero draws nothing) — see Approval and demo
PolicyGrid(policy, PolicyGridOpts{...})the role × module grid of an auth.Policy, as a form; a ScopedPolicy (which auth.Policy is, through ScopeNameOf and ScopeNames) also gets a scope select per cell — organisation, unit, unit and below — renamed with PolicyGridOpts.ScopeLabels — see Auth and demo
TaskTable(c, tasks, TaskTableOpts{...}), TaskProgress(c, tasks, id)the background work: the list with its states and the progress of one run — see Tasks and demo
WebhooksPanel(c, hooks, deliveries, WebhooksOpts{...})the subscriptions, the secret and the delivery log of a webhook.Hooks — see Webhook and demo
Empty(EmptyOpts{...}), EmptyError(c, title, err, action)the screen with nothing to show, and the one that could not load: the message is what the person reads, and err appears only in development — see demo
Status(enum, value)one value of a trilha.Enum as a badge with its label and its tone; a value the enum no longer knows renders muted instead of taking the screen down — see demo

Trees#

A hierarchy with thousands of nodes is the component people go to npm for: expanding, searching and the keyboard are each easy and together are three hundred lines. ui.Tree is the server's version of it — the server already knows the tree, so the browser never has to.

ui.Tree(ui.TreeOpts{
	Nodes:   roots,                   // with the path down to Current already inside
	Source:  "/classification/nodes", // GET ?parent=100.1 answers the children
	Current: doc.Code,
	Label:   "Classification plan",
})
SymbolRole
Tree(TreeOpts{...})the hierarchy; each node is a <details>, so it opens with no script at all
TreeNode{Value, Label, Leaf, Href, Children, Open, Path, Unpickable}one node; Children travel with it when known, and Unpickable keeps a branch browsable without rendering a radio
TreeItems(nodes, TreeOpts{...}) / TreeNodes(items, of)what a source route answers: the children of one node, as HTML
TreePicker(TreePickerOpts{...})the same tree as a form field: a radio per node
TreeScript(c)loads ui.tree.js; a page with no tree does not download it

A node is <details>, and that is the whole no-JavaScript story. What the script adds is fetching the children the first time a branch opens, instead of asking the server for a whole page. A node whose Children are already in Nodes never asks for anything — which is how the path down to the current node arrives open and complete on the first render, including after a 422 brought the form back.

TreeOpts.Pending and TreeOpts.Fail replace the default lazy-loading messages (Loading… and Could not load.); TreePickerOpts forwards the same fields. The pending sentence is server-rendered, so a lazy branch never opens into a silent empty box without JavaScript.

The roles are the real ones (tree, treeitem, group, aria-expanded), the arrows move through what is visible, Home and End jump to the ends, and * expands everything. Only the first node is in the tab order: the tree is one stop, and the arrows move inside it.

The picker#

ui.Field("code", "Classification", ui.TreePicker(ui.TreePickerOpts{
	Name:   "code",
	Value:  form.Code,
	Nodes:  plan.Roots(form.Code),
	Source: "/classification/nodes",
	Search: "/classification/search", // GET ?q= answers flattened nodes, each with its Path
}))

What posts is a radio, which is the whole reason this works with no script: somebody browses the same <details> and picks the same radio, and the form posts the same field. There is no hidden input to keep in sync and nothing to resolve on the server.

With the script, typing asks Search and puts the matches where the tree was, each with the ancestry it came from — a code found out of context does not say where it lives. Clearing the box brings the tree back from memory, without asking again.

A tree of radios announces itself as a group of choices, not as a navigation: it is a form field, and that is one role and not two.

ui.js#

Everything by attribute, no initialization: [data-ui-tabs], [data-ui-dialog-open=id], [data-ui-dialog-close], [data-ui-fade=ms], [data-ui-show-when], [data-ui-toast=text] (data-ui-toast-kind), [data-ui-theme-toggle], [data-ui-tooltip=text], [popover].ui-menu. It also exposes window.ui.toast(text, {kind, ms}), ui.formError(form, message, options), ui.clearFormErrors(form), ui.formPending(form), ui.fade(el), ui.evalShowWhen(root) and ui.applyTheme("dark"|"light"). Elements inserted later (HTMX, fetch) need ui.evalShowWhen(el)/ui.fade(el)/ui.initTooltips(el) if they use those attributes — ui.hydrate(el) does the three at once.

Async forms#

Keep event.currentTarget before the first await: after the listener yields back to the browser, event.currentTarget becomes null. ui.formPending marks the form with aria-busy, disables submit controls, and returns the function that restores their original state. ui.formError keeps the failure inside the form — including a form inside a dialog — and can mark and focus the field responsible for it.

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const form = event.currentTarget;
  ui.clearFormErrors(form);
  const settled = ui.formPending(form);
  try {
    await save(new FormData(form));
  } catch (error) {
    ui.formError(form, error.message, {
      field: form.elements.email,
      action: { href: "/help", label: "How to fix this" },
    });
  } finally {
    settled();
  }
});

The message is assigned through textContent, never innerHTML. The action link only appears when the caller explicitly provides href and label. Put ui.FormError() before the fields to reserve the feedback location; when omitted, the runtime creates the element.

Fragments#

[data-trilha-target=id] on an <a> or <form> (see ui.Swap) makes the kit request the same URL with the Trilha-Fragment header and swap element #id for the HTML that comes back. Details: the target gets aria-busy while it waits; 204 with Trilha-Location becomes a real navigation; 422 focuses the first [aria-invalid=true] (a form refused with a whole page, without a swap, gets the same on load), otherwise focus (and the caret) return to the field in use; what came in is hydrated (fade, show-when) and fires trilha:swap (detail.target, detail.status). On 5xx, a network error or a fragment without the id, the kit gives up and navigates/submits normally. ui.swap(id, html, status) and ui.hydrate(el) do the swap by hand (ui.swap returns a promise: the replacement may be running inside a view transition). See Interactivity.

Waiting#

SymbolWhat it does
ui.Indicator(id)this element appears only while target id is waiting past the threshold
ui.PendingAfter(ms)the threshold on the trigger; default 120 ms, zero or less means the default
ui.NoTransition()no crossfade on this trigger
ui.Spinner(attrs…)a turning ring sized by the font it sits in, hidden from assistive technology

See it live: demo.

While a target waits, data-trilha-pending is on the target, the trigger and every indicator of that target, and aria-busy is on the target; trilha:pending and trilha:settled fire on document with detail.target and detail.id. A second trigger for a target already in flight is ignored. The replacement runs inside document.startViewTransition where it exists and where the system does not ask for less motion.

Client navigation is off until you ask for it, in two places:

SymbolRole
ui.Navigate(id) h.Nodemarks a region: a click on a same-origin link inside it replaces element #id with the same element from the next page. An empty id means the marked element itself
ui.NoNavigate() h.Nodekeeps one link out of it (a download, another app, a route that must reload)
ui.Prefetch(), ui.NoPrefetch(), ui.PrefetchTTL(ms)prefetch on intent (pointer resting 80 ms, keyboard focus, touch) inside a Navigate region, a link kept out, and how long an answer waits for its click (default 10 s) — see Asking before the click
ui.NavigateFocus(mode) h.Nodewhere the focus goes after a client navigation: "h1" (default, the region's first heading), "region" or "none"; the new title is announced either way
ui.NavigateScript(c) h.Node<script defer src=ui.nav.js>; put it once, in the layout of the area that uses it

What the browser keeps doing: the address in the bar is the one a normal navigation would use, Back and Forward work (and restore the scroll position of the entry they return to), Cmd/Ctrl-click and middle click open a tab, target, download and links to another origin are untouched. What the kit adds: aria-busy on the region while it waits, focus moved to what came in, ui.hydrate and the trilha:swap event, and one request at a time — a second click aborts the first. On 5xx, a network error, a redirect or a page that does not contain the id, it gives up and navigates for real.

The behavior is a separate file so an app that does not use it does not download it, and ui.Head does not load it. A link marked with ui.Swap stays with fragments: it asks for a piece of the page, not for the next page.

Upload with progress#

A form that sends a file is a form: method="post", enctype="multipart/form-data", the CSRF field. Three symbols add the progress bar on top of it, and it is off until you ask:

SymbolRole
ui.UploadTo(id) h.Nodeon the <form>: send it with XHR and swap #id with what comes back
ui.UploadBar(attrs…) h.Nodethe <progress> the kit fills in; hidden until the send starts
ui.UploadScript(c) h.Node<script defer src=ui.upload.js>, once per page that uploads

The request carries Trilha-Fragment: id, so the handler answers the piece with the same c.Fragment() it already uses. While it uploads, the bar gets value/max from the browser's own progress event (and loses value — an indeterminate bar — when the total is not known), and a trilha:upload event bubbles with detail: {loaded, total, form}. On a 5xx, a network error or a piece without the id, the form submits for real: the user sees the page reload, not a button that did nothing.

The attribute is data-trilha-upload, not data-trilha-target, so the fragment handler in ui.js does not submit the same form a second time. The body limit is the server's business — see AllowBody.

ui.Dropzone(ui.DropzoneOpts{Name, Accept, MaxSize, Single, Attrs}) puts a drop area over the file input: a <label> that takes the drop and a <ul class="ui-queue"> with one line per file. With ui.UploadTo on the form the queue sends one file per request, so each line gets its own progress and its own answer — a message that names files[2] has nowhere to go when three files travel in one body. The swapped element must be outside the dropzone, or the queue is destroyed halfway through.

Accept and MaxSize in the options only spare the user a round trip: the browser can be told anything. What decides is c.Files with its FileRules — the same rules, applied to bytes that already arrived. Without JavaScript the input is a plain multiple field and the form posts every file at once, into the same handler.

Recorder#

ui.Recorder(c, ui.RecorderOpts{Action, Name, MaxSeconds, Mime, Label, Target, Attrs}) is the microphone half of Audio: a form that records at the counter and posts the recording to a route, with the progress bar of the upload above.

ui.Recorder(c, ui.RecorderOpts{Action: "/api/voice", Target: "answer", MaxSeconds: 60})
ui.RecorderScript(c)
ui.UploadScript(c)
app/…/page.go
ui.Recorder(c, ui.RecorderOpts{
	Action: "/api/voice", Target: "answer", MaxSeconds: 60,
})
ui.RecorderScript(c)
ui.UploadScript(c)
result
0:00
Use this pattern — upload-progress

Files sent with a progress bar and checked by content on the server, working without JavaScript too.

Components

ui.UploadTo ui.UploadBar ui.UploadScript ui.Field ui.Input ui.InvalidIf ui.Errors ui.Submit ui.PageHeader ui.Stack

Data it needs

trilha.FileRules (size, count, accepted types, checked against the bytes); Save(c, up) keeps one *trilha.Upload in your blob store.

Accessibility

  • The progress bar has an aria-label and is a real <progress>, read as a percentage.
  • The file input has a visible label that says the limits before anybody tries.
  • A refused file comes back as a 422 with the message on the field and the focus on it.

The whole page.go (compiles, at most 60 lines)

// Package uploadprogress is the upload-progress pattern: files sent with a
// progress bar, checked by content on the server, and the same form working
// with JavaScript off.
package uploadprogress

import (
	"errors"
	"net/http"

	"github.com/emersonjoe/trilha"
	"github.com/emersonjoe/trilha/h"
	"github.com/emersonjoe/trilha/ui"
)

// rules are checked against the bytes, not against what the browser claims.
var rules = trilha.FileRules{MaxSize: 10 << 20, MaxFiles: 5, Accept: []string{"image/*", "application/pdf"}}

// Save keeps one file — your blob store. The upload is closed after it.
var Save = func(c *trilha.Ctx, up *trilha.Upload) error { return nil }

// Page draws the form.
func Page(c *trilha.Ctx) (h.Node, error) { return screen(c, nil), nil }

// POST receives the files. A refusal answers the fragment the kit swaps in.
func POST(c *trilha.Ctx) error {
	ups, err := c.Files("files", rules)
	if err != nil {
		var fe trilha.FieldErrors
		if !errors.As(err, &fe) {
			return err
		}
		if c.Fragment() == "upload" {
			return c.Render(http.StatusUnprocessableEntity, panel(c, fe))
		}
		return c.Render(http.StatusUnprocessableEntity, screen(c, fe))
	}
	for _, up := range ups {
		err := Save(c, up)
		up.Close()
		if err != nil {
			return err
		}
	}
	c.Flash(ui.FlashSuccess, "Sent.")
	return c.Redirect(c.Request().URL.Path)
}

func screen(c *trilha.Ctx, errs trilha.FieldErrors) h.Node {
	return ui.Stack(ui.PageHeader("Files"), panel(c, errs), ui.UploadScript(c))
}

func panel(c *trilha.Ctx, errs trilha.FieldErrors) h.Node {
	return h.Form(h.ID("upload"), h.Method("post"), h.Enctype("multipart/form-data"), ui.UploadTo("upload"),
		h.Class("ui-stack"), trilha.CSRFInput(c),
		ui.Field("files", "Files (images or PDF, 10 MB each)", ui.Input(h.ID("files"), h.Name("files"),
			h.Type("file"), h.Attr("multiple", ""), ui.InvalidIf(errs, "files")), ui.Errors(errs, "files")),
		ui.UploadBar(h.Aria("label", "Upload progress")), ui.Submit(h.Text("Send")))
}

trilha ui patterns upload-progress · get_pattern {"name":"upload-progress"}

Field of RecorderOptsRole
Actionthe route that receives the recording, as any form's action
Namethe field the route reads with c.File; default "audio"
MaxSecondsstops the recording on its own; zero leaves it to the person
Mimewhat MediaRecorder is asked for when the browser supports it; default "audio/webm"
Labelthe text of the record button; the default comes from the locale
Targetthe id the answer swaps, as in ui.UploadTo; empty leaves a plain form

ui.RecorderScript(c) loads ui.recorder.js, which shows the record button when navigator.mediaDevices and MediaRecorder are there, counts the seconds, puts the recording into the file field and submits the form — from there on it is ui.UploadScript's upload, with the progress and the swap. What is left when the script is off, the browser has no MediaRecorder or the person denies the microphone is the <input type="file" accept="audio/*" capture> the markup always carries: on a phone it opens the recorder of the system.

The browser only hands over the microphone over HTTPS and under a permissions policy that allows it, and the framework's default denies it. An app that records sets it once:

a.Config().Security.PermissionsPolicy = "camera=(), microphone=(self), geolocation=(), payment=(), usb=()"

The CSP does not change: the recorder is a file of the kit, loaded like any other script. The route reads one file with c.File and its FileRules, and what it does with it — ai.Transcribe, an answer, ai.Speak — is the voice section of the AI chapter.

Combobox#

A text field that searches a list is two inputs: the one the person types in, and the one the form sends. ui.Combobox renders both — a visible <input role=combobox name="<name>_q"> and an <input type=hidden name="<name>"> with the chosen value — plus the <ul role=listbox> of options.

Field of ComboboxOptsRole
Namename of the hidden field; the visible one is Name + "_q"
Value / Labelwhat was chosen and what is written for it (the round trip)
Options []Optiona short list: filtered in the browser, no request
Source stringthe URL that searches; the answer is ui.ComboboxOptions(...)
With []stringother fields of the same form to carry in the query (?uf=SP&q=camp)
MinChars, Debouncewhen to search (default 1 character, 200 ms)
Placeholder, Required, Attrsas in Input

ComboboxOptions(items []T, of func(T) (value, label string)) is the answer of the search route: only the <li>s, no envelope. The request carries Trilha-Fragment, so the route answers with c.HTML and the page's layout stays out of it.

func GET(c *trilha.Ctx) error {
	return c.HTML(200, ui.ComboboxOptions(
		Search(c.Query("uf"), c.Query("q")),
		func(city string) (string, string) { return city, city },
	))
}

Without JavaScript nothing breaks: the visible field is a normal text input, so the form still arrives with cidade_q filled in and cidade empty. Resolving the typed text against the list is the server's job — the same list the search route reads.

The behavior is ui.combo.js, written to public/ with the rest of the kit. ui.js loads it the first time a page — or a fragment swapped into it — has a combobox, from the versioned URL ui.Head puts on its own tag, so a page without one does not download it.

Markdown#

func Markdown(src string, opt MarkdownOpts) h.Node

A model writes Markdown, and so does whoever types in a form. Markdown turns it into nodes: paragraphs, emphasis, headings, lists, quotes, inline and fenced code, GFM tables, links and line breaks.

h.Div(ui.Markdown(doc.Summary, ui.MarkdownOpts{}))

There is no raw HTML, and no way to turn it on. A <script> in the text is a <script> on the screen, as text — the return is a tree, not a string, so the escaping is not a rule anybody has to remember. That is the whole reason this exists instead of an h.Raw around a converter.

Field of MarkdownOptsWhat it does
HeadingBasethe level # becomes (default 3, so it does not compete with the page's <h1>); ###### never goes past <h6>
Images![alt](url) renders an <img>; off by default, and the URL is validated either way
Classan extra class on the wrapper, which is always ui-md

Links only survive as links when the address is http, https, mailto, or relative (/, #, ./); anything else — javascript:, data: — stays as the text it was. An external link gets rel="noopener nofollow ugc".

The site's own pages use a different renderer (site/internal/md): another dialect, over text from this repository. ui.Markdown is for text nobody in this repository wrote.

Chat#

func Chat(c *trilha.Ctx, o ChatOpts) h.Node
func ChatScript(c *trilha.Ctx) h.Node
func ChatHTML(text string) string

The conversation with an agent: the bubbles, the field and the button. The route on the other side is ai.Serve.

ui.Chat(c, ui.ChatOpts{Action: "/api/chat", History: msgs, Greeting: "Ask me anything."})
ui.ChatScript(c)   // once, in the layout
Field of ChatOptsWhat it does
Actionthe route that answers — the only one that has to be filled in
Historywhat was said before, oldest first. It is the app's: the framework keeps no session
GreetingMarkdown shown while the history is empty
IDthe element id and the prefix of the ids inside (default chat)
Placeholder, Submit, Labelthe words on the screen
MaxLengthcaps the field (default 4000; negative lifts it)
Stepsshows the tool the agent called and what came back
Markdownthe MarkdownOpts for the answers

ChatMessage{Role, Text, Sources} is one turn. Role: "assistant" is rendered as Markdown; Role: "user" is rendered as text — what somebody typed is never markup.

ChatSource{Title, URL, Snippet} attaches the pages or documents supporting an assistant answer. Title and snippet are escaped, the URL goes through the Markdown safe-link rule, and streaming accepts sources on a chunk or on the final done event. Sources are therefore part of the answer whether it arrived all at once or token by token.

With ChatScript the answer arrives word by word and the Markdown is rendered when the message ends: pass ui.ChatHTML to ai.ServeOpts.HTML and the finished bubble looks exactly like a reloaded page. Without the script the form still submits and the route answers the whole thing at once, so nothing on the screen depends on the script running.

Field of ChatOptsWhat it does
Contextwhat the page knows and the model does not: {"invoice": id} becomes hidden ctx.* fields, sent with the message and read by ai.ServeOpts.Context

Assistant#

func Assistant(c *trilha.Ctx, o AssistantOpts) h.Node

The other shape of a chat: a button fixed in a corner that opens a panel over the screen instead of replacing it. Mount it once, in the layout of the area that has it.

ui.Assistant(c, ui.AssistantOpts{
	Action: "/api/assistant",
	Page:   "/panel/assistant",           // the same conversation, as a page
	Hint:   "Asking about invoice " + id, // what it knows right now
	Chat:   ui.ChatOpts{Context: map[string]string{"invoice": id}},
})
ui.ChatScript(c)
Field of AssistantOptsWhat it does
Actionthe route that answers, the ai.Serve one
Pagethe conversation as a page: where the launcher points when the script is not there
Label, Title, Hintthe word on the launcher, the heading of the panel, and the line under it
Icona kit icon on the launcher; empty is the label alone
IDthe prefix of every id inside (default assistant) — two assistants need two
Chatthe conversation itself: history, greeting, context

The launcher is a link before it is a button. Without JavaScript it goes to Page, which is the same conversation as a whole page; with JavaScript the kit's script opens the <dialog> instead, and the browser gives the focus trap and the Escape key. aria-expanded follows the panel, aria-controls names it, and it is server HTML — a page with an assistant is not an island.

It is composition, not a second chat: the panel holds a Chat, so the streaming, the Markdown and the errors are the ones that already exist.

See it running in the assistant demo: the real component over a small screen, answered in the browser in ai.Serve's contract — the launcher, the panel, the streaming and the context are the ones an application gets.

DeadlineCards#

func DeadlineCards(c *trilha.Ctx, s trilha.DeadlineSummary) h.Node
func DeadlineList(c *trilha.Ctx, items []trilha.Deadline, o DeadlineListOpts) h.Node
func DeadlineBadge(c *trilha.Ctx, items []trilha.Deadline) h.Node

The panel of a trilha.Deadlines summary: the cards on top, the list under them, the number beside a menu item.

resumo := trilha.Deadlines(itens, trilha.DeadlineOpts{Now: time.Now().In(c.Location())})

ui.DeadlineCards(c, resumo)
ui.DeadlineList(c, itens, ui.DeadlineListOpts{Limit: 10, More: "/prazos"})
ui.DeadlineBadge(c, resumo.Overdue)

The cards follow the order of the horizons, so they do not move between reloads, and the overdue one only turns red when there is something to be red about — a zero in red teaches people to look past the colour, and then the three is looked past too. The last card is Next: the count with no example beside it is a number somebody has to click to understand.

DeadlineListOpts takes Limit (with More for the "and 12 more" link), Owner for the column of who it is on, RowAction func(trilha.Deadline) h.Node for one application-owned action at the end of each row, Empty for the sentence with nothing to say, and Now — the clock late is measured against, so a test does not fail on its own the next morning. Dates are written by ui.Date with Relative, and a row whose day has ended carries ui-late, the same class the inbox uses: late looks the same everywhere in an application, or it looks like a bug.

DeadlineBadge draws nothing for an empty list, and says in aria-label what the colour says. No recipe writes this screen — trilha.Deadlines is a runtime primitive, used straight from an app's own page.

app/…/page.go
summary := trilha.Deadlines(items, trilha.DeadlineOpts{Now: time.Now().In(c.Location())})

ui.DeadlineCards(c, summary)
ui.DeadlineList(c, items, ui.DeadlineListOpts{Limit: 10, More: "/deadlines"})
ui.DeadlineBadge(c, summary.Overdue)
result
Overdue1
SSL certificate — api.example.com
In 7 days1
Vendor contract — Acme
In 30 days2
Vendor contract — Acme
In 90 days2
Vendor contract — Acme
WhatKindDue
SSL certificate — api.example.comcertificate
Vendor contract — Acmecontract
Vendor contract — Acmecontract
1

VersionList#

func VersionList(c *trilha.Ctx, rows []VersionRow, o VersionOpts) h.Node
func VersionBadge(n int, published bool, words ...map[string]string) h.Node
func Changed(before, after map[string]string) []string

The history of a trilha.Versioned[T]: who, when, what changed, and the buttons to publish or go back. Newest first, because a history is read from what a thing is now backwards.

There is no JavaScript and no diff library: what changed is a list of field names, and ui.Changed works it out from two maps of strings — the honest half of a diff, and the half somebody reads before opening a version. The published row carries no buttons, because it is already the one everybody reads. No recipe writes this screen either — trilha.Versioned[T] is a runtime primitive.

app/…/page.go
n, _ := Models.Save(c, id, m, "adjusted")
_ = Models.Publish(c, id, n)

ui.VersionList(c, rows, ui.VersionOpts{Restore: "/admin/models/1/restore", CSRF: trilha.CSRFInput(c)})
// row.Changed = ui.Changed(before, after)
result
VersionByWhenChanged
v3 · publishedAna Souza
Title
v2Ana Souzafirst draft

ConnectionsPanel#

func ConnectionsPanel(c *trilha.Ctx, x *trilha.Connections, o ConnectionsOpts) h.Node
func ConnectionStatus(c *trilha.Ctx, t *trilha.ConnectionTest) h.Node
func ParseConnectionForm(c *trilha.Ctx) trilha.Connection

The screen of a trilha.Connections: the list grouped by kind — name, URL, auth, the badge of the last test — and the form of one connection, new or being edited (Editing, with Errors from a refused save). Every button is a form that posts to Path with a hidden _action (save, test, delete) and the id, plus the CSRF node you pass; nothing needs script. The user and header-name fields show with ShowWhen for the auth that uses them.

The secret is the one thing the screen never contains: the field is SecretField, which renders empty and says "leave blank to keep", and ParseConnectionForm reads it back as a Secret so an empty one keeps the previous value on Save. When the store keeps the credential in another system, set Connection.HasSecret and render SecretFieldWithPresence: the empty update still means keep, without reading the credential back into this process.

trilha add connections writes the package, this screen and the test.

app/…/page.go
ui.ConnectionsPanel(c, connections.Connections, ui.ConnectionsOpts{
	Path: "/admin/connections", CSRF: trilha.CSRFInput(c), Editing: editing, Errors: errs,
})
result

API

NameURLAuthLast test
Billing APIhttps://billing.example.comBearer tokenokEdit
data-ui-confirm="Delete this connection?" data-ui-confirm-description="Anything using it stops working immediately."

MCP server

NameURLAuthLast test
Docs MCPhttps://docs.example.com/mcpNonefailedEdit
data-ui-confirm="Delete this connection?" data-ui-confirm-description="Anything using it stops working immediately."

New connection

The base address. Paths are added by whoever calls it.

Nothing stored yet. Leave blank to keep the current one.

One per line, Name: value. Not a place for a secret.

Audio#

ui.Audio(c, "/recordings/42", ui.AudioOpts{
	Title:    "Interview",
	Type:     "audio/mpeg",
	Duration: 3*time.Minute + 12*time.Second,
	Download: "/recordings/42?download=1",
	Preload:  ui.PreloadMetadata,
})

Audio uses the browser's native controls and the same title bar and escape routes as Preview. PreloadNone is the default for lists; PreloadMetadata fetches duration metadata; PreloadAuto leaves the choice to the browser. A MIME type that is not audio renders an honest "cannot play" state and keeps the download action instead of leaving a broken player.

InstallApp#

InstallApp is the progressive invitation used by the pwa recipe. It keeps a useful browser-menu instruction without JavaScript, exposes the native Chromium prompt when available, explains the Safari steps on iOS and disappears when c.Standalone() says the app is already installed. InstallAppOpts lets the application replace the script, manifest, help address and every visible sentence.

Formatting#

A date, a size, a duration and a count are not domain: they are the same in every application, and every application writes them again — usually four times, slightly differently each time, and usually ignoring the time zone.

// app/setup.go
func Config(cfg *trilha.Config) {
	cfg.Locale = "pt-BR"                  // "en" is the zero value
	cfg.TimeZone = "America/Sao_Paulo"    // empty means UTC
}
ui.Date(c, doc.CreatedAt)                 // <time datetime="…">Sep 8, 2026 3:04 PM</time>
ui.Date(c, doc.CreatedAt, ui.Relative())  // 3min ago, absolute in the title
ui.Date(c, doc.CreatedAt, ui.DateOnly())  // Sep 8, 2026
ui.Date(c, doc.CreatedAt, ui.TimeOnly())  // 3:04 PM     (the day is already in the heading)
ui.Bytes(c, doc.Size)                     // 1.4 MB      (pt-BR: 1,4 MB)
ui.Duration(c, job.Elapsed)               // 2 min 13 s
ui.Number(c, total)                       // 12,345      (pt-BR: 12.345)
ui.Number(c, price, ui.Decimals(2))       // 1,234.56

The options are ui.FormatOpt values — Relative, DateOnly, TimeOnly, Decimals — which is what lets a column of a ui.DataTable carry the format it wants next to the field it reads.

See it live: demo.

The rules worth knowing#

A missing value is a dash. The zero time.Time, a nil *time.Time, a zero size: all render — in muted text. A zero date printing as 01/01/0001 is the bug this removes.

datetime is always the instant. The text is local and translated; the machine-readable attribute is RFC 3339 in UTC, so a copy-paste, a sort or a screen reader gets the fact and not the presentation.

Relative does not move. It writes "3min ago" and keeps the absolute time in the title. Nothing updates it: the kit has no clock and does not want one. A screen that needs the number to keep moving puts the piece in a ui.Poll — a decision the page makes and pays for, once.

Bytes is base 10. kB, MB, GB: what the file manager of whoever is reading already shows them. The exact byte count stays in the title.

Decimal halves round away from zero. Number, Bytes and Duration use the same half-expand rule as JavaScript and ICU for the short decimal they display; values such as 1.25 with one decimal become 1.3, not the banker's-rounding 1.2 from strconv.

An unknown time zone falls back to UTC and says so in the log. Falling back in silence would shift every timestamp on the screen and nothing would look broken.

They take a Ctx, and that is on purpose#

ui.Date(c, t) and not ui.Date(t). A package-level language would be shared by two applications running in one process — which is exactly what trilha.Provide and the embedded app exist to support — and the second one to boot would silently change the first. ui.Head, ui.Flashes and ui.DataTable take a Ctx for the same kind of reason.

Money is not here#

The currency, where the symbol goes, how a negative reads: those are the application's to decide, and a framework that guessed would be wrong in somebody's country. ui.Number(c, v, ui.Decimals(2)) with the symbol written beside it is the whole recipe.

trilha audit warns about a time.Format("02/01/2006") inside app/: a layout in the page ignores Config.TimeZone, which is how a date shown to somebody in another country ends up simply wrong.

Theme#

ui.theme.css defines, in :root and .dark, the shadcn/ui v4 variables: --background/--foreground, --card/--card-foreground, --popover/…, --primary/…, --secondary/…, --muted/…, --accent/…, --destructive, --border, --input, --ring, --chart-1…5, --sidebar…, --radius. Trilha extends that contract with semantic intent tokens (--success-*, --warning-*, --info-*, --destructive-*) and shadows (--ui-shadow-sm/md/lg, plus the --shadow alias). Use those tokens for operational states instead of primitive colors. ui.css derives --radius-sm/md/lg/xl. Dark mode is the dark class on <html> (the ui.Head script applies the saved or system preference before the first paint).

Hooks#

Every class the kit writes has a rule in ui.css, with a short list of exceptions: classes written so the app's own stylesheet has something to hang on, with no rule of their own because the kit has nothing to say there. A test in ui/ keeps the list this short — a class outside it with no rule is a bug, like the ui-inline-form the connections panel misspelled for two releases.

HookOn
ui-assistant, ui-assistant-labelthe Assistant widget and its label
ui-audiothe Audio player
ui-btn-primarythe default Button, beside the variants that do have a rule
ui-connections, -actions, -buttons, -form, -group, -kind, -listthe parts of ConnectionsPanel
ui-deadline-cardeach card of DeadlineCards
ui-install-appthe InstallApp button
ui-list-formthe form around a DataTable with selection
ui-md-img, ui-md-listimages and lists inside Markdown
ui-quotea blockquote inside Markdown
ui-shell-userthe user corner of the Shell
ui-taskthe box of TaskProgress
ui-tree-branch, ui-tree-radiothe nodes of a Tree
ui-webhooks, -events, -form, -titlethe parts of WebhooksPanel

CLI#

trilha ui [--force] [--css-only|--js-only] writes the files in public/: ui.theme.css is only created (never overwritten); ui.css, ui.js, ui.nav.js, ui.upload.js, ui.recorder.js, ui.live.js, ui.chat.js and ui.island.js are updated when they equal a previous version and, if you edited them, only with --force.

Patterns#

A component answers "how do I draw a table"; a pattern answers "how do I build a listing here". ui.Patterns() returns six, each a complete page.go from examples/patterns/ — the repository compiles every one of them, and none is longer than 60 lines:

PatternWhen
list-with-filtera listing somebody searches, filters, orders and pages, all in the address
async-forma form that posts without leaving the page, the 422 beside the wrong field
approval-inboxwhat waits for a person's decision, with the reason in the same form
dashboard-chartthe numbers on top and server-rendered charts beside them
master-detaila list and the chosen row, the choice in the address
upload-progressfiles with a progress bar, checked by content, working without JavaScript

trilha ui patterns lists them (--json for a machine, a name for one of them), the MCP tool get_pattern answers one, trilha ui describe <Component> says which patterns a component takes part in, and each pattern's demo on this site ends with its "Use this pattern" section.